Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 33 additions & 1 deletion packages/providers/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
30 changes: 28 additions & 2 deletions packages/providers/src/__tests__/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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);
});
});
});
21 changes: 21 additions & 0 deletions packages/providers/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,27 @@ export function pickRandomItem<T>(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
*/
Expand Down
95 changes: 93 additions & 2 deletions packages/providers/src/ws/WsProvider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
/**
Expand All @@ -25,6 +25,18 @@ export interface WsConnectionState {
*/
export type WsEndpointSelector = (info: WsConnectionState) => string | Promise<string>;

/**
* A map of custom HTTP headers to send along with the websocket opening handshake
*/
export type WsRequestHeaders = Record<string, string>;

/**
* 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<WsRequestHeaders>;

export interface WsProviderOptions {
/**
* The websocket endpoint to connect to. Can be:
Expand Down Expand Up @@ -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<WsProviderOptions> = {
retryDelayMs: 2500,
timeout: 30_000,
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -137,6 +186,9 @@ export class WsProvider extends SubscriptionProvider {
// Recovering promise for request queueing during reconnection
#recovering?: Deferred<void>;

// 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();

Expand Down Expand Up @@ -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<WsRequestHeaders | undefined> {
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;
Expand Down
Loading
Loading