@solana/rpc-subscriptions-spec 6.3.1 → 6.3.2-canary-20260313112147
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -6
- package/src/index.ts +35 -0
- package/src/rpc-subscriptions-api.ts +168 -0
- package/src/rpc-subscriptions-channel.ts +101 -0
- package/src/rpc-subscriptions-pubsub-plan.ts +241 -0
- package/src/rpc-subscriptions-request.ts +17 -0
- package/src/rpc-subscriptions-transport.ts +32 -0
- package/src/rpc-subscriptions.ts +95 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@solana/rpc-subscriptions-spec",
|
|
3
|
-
"version": "6.3.
|
|
3
|
+
"version": "6.3.2-canary-20260313112147",
|
|
4
4
|
"description": "A generic implementation of JSON RPC Subscriptions using proxies",
|
|
5
5
|
"homepage": "https://www.solanakit.com/api#solanarpc-subscriptions-spec",
|
|
6
6
|
"exports": {
|
|
@@ -33,7 +33,8 @@
|
|
|
33
33
|
"types": "./dist/types/index.d.ts",
|
|
34
34
|
"type": "commonjs",
|
|
35
35
|
"files": [
|
|
36
|
-
"./dist/"
|
|
36
|
+
"./dist/",
|
|
37
|
+
"./src/"
|
|
37
38
|
],
|
|
38
39
|
"sideEffects": false,
|
|
39
40
|
"keywords": [
|
|
@@ -55,10 +56,10 @@
|
|
|
55
56
|
"maintained node versions"
|
|
56
57
|
],
|
|
57
58
|
"dependencies": {
|
|
58
|
-
"@solana/errors": "6.3.
|
|
59
|
-
"@solana/promises": "6.3.
|
|
60
|
-
"@solana/
|
|
61
|
-
"@solana/
|
|
59
|
+
"@solana/errors": "6.3.2-canary-20260313112147",
|
|
60
|
+
"@solana/promises": "6.3.2-canary-20260313112147",
|
|
61
|
+
"@solana/rpc-spec-types": "6.3.2-canary-20260313112147",
|
|
62
|
+
"@solana/subscribable": "6.3.2-canary-20260313112147"
|
|
62
63
|
},
|
|
63
64
|
"peerDependencies": {
|
|
64
65
|
"typescript": "^5.0.0"
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This package contains types that describe the implementation of the JSON RPC Subscriptions API,
|
|
3
|
+
* as well as methods to create one. It can be used standalone, but it is also exported as part of
|
|
4
|
+
* Kit [`@solana/kit`](https://github.com/anza-xyz/kit/tree/main/packages/kit).
|
|
5
|
+
*
|
|
6
|
+
* @example
|
|
7
|
+
* ```ts
|
|
8
|
+
* const rpcSubscriptions =
|
|
9
|
+
* // Step 1 - Create a `RpcSubscriptions` instance. This may be stateful.
|
|
10
|
+
* createSolanaRpcSubscriptions(mainnet('wss://api.mainnet-beta.solana.com'));
|
|
11
|
+
* const response = await rpcSubscriptions
|
|
12
|
+
* // Step 2 - Call supported methods on it to produce `PendingRpcSubscriptionsRequest` objects.
|
|
13
|
+
* .slotNotifications({ commitment: 'confirmed' })
|
|
14
|
+
* // Step 3 - Call the `subscribe()` method on those pending requests to trigger them.
|
|
15
|
+
* .subscribe({ abortSignal: AbortSignal.timeout(10_000) });
|
|
16
|
+
* // Step 4 - Iterate over the result.
|
|
17
|
+
* try {
|
|
18
|
+
* for await (const slotNotification of slotNotifications) {
|
|
19
|
+
* console.log('Got a slot notification', slotNotification);
|
|
20
|
+
* }
|
|
21
|
+
* } catch (e) {
|
|
22
|
+
* console.error('The subscription closed unexpectedly', e);
|
|
23
|
+
* } finally {
|
|
24
|
+
* console.log('We have stopped listening for notifications');
|
|
25
|
+
* }
|
|
26
|
+
* ```
|
|
27
|
+
*
|
|
28
|
+
* @packageDocumentation
|
|
29
|
+
*/
|
|
30
|
+
export * from './rpc-subscriptions-request';
|
|
31
|
+
export * from './rpc-subscriptions';
|
|
32
|
+
export * from './rpc-subscriptions-api';
|
|
33
|
+
export * from './rpc-subscriptions-channel';
|
|
34
|
+
export * from './rpc-subscriptions-pubsub-plan';
|
|
35
|
+
export * from './rpc-subscriptions-transport';
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import { Callable, RpcRequest, RpcRequestTransformer } from '@solana/rpc-spec-types';
|
|
2
|
+
import { DataPublisher } from '@solana/subscribable';
|
|
3
|
+
|
|
4
|
+
import { RpcSubscriptionsChannel } from './rpc-subscriptions-channel';
|
|
5
|
+
import { RpcSubscriptionsTransportDataEvents } from './rpc-subscriptions-transport';
|
|
6
|
+
|
|
7
|
+
export type RpcSubscriptionsApiConfig<TApiMethods extends RpcSubscriptionsApiMethods> = Readonly<{
|
|
8
|
+
planExecutor: RpcSubscriptionsPlanExecutor<ReturnType<TApiMethods[keyof TApiMethods]>>;
|
|
9
|
+
/**
|
|
10
|
+
* An optional function that transforms the {@link RpcRequest} before it is sent to the JSON RPC
|
|
11
|
+
* server.
|
|
12
|
+
*
|
|
13
|
+
* This is useful when the params supplied by the caller need to be transformed before
|
|
14
|
+
* forwarding the message to the server. Use cases for this include applying defaults,
|
|
15
|
+
* forwarding calls to renamed methods, and serializing complex values.
|
|
16
|
+
*/
|
|
17
|
+
requestTransformer?: RpcRequestTransformer;
|
|
18
|
+
}>;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* A function that implements a protocol for subscribing and unsubscribing from notifications given
|
|
22
|
+
* a {@link RpcSubscriptionsChannel}, a {@link RpcRequest}, and an `AbortSignal`.
|
|
23
|
+
*
|
|
24
|
+
* @returns A {@link DataPublisher} that emits {@link RpcSubscriptionsTransportDataEvents}
|
|
25
|
+
*/
|
|
26
|
+
type RpcSubscriptionsPlanExecutor<TNotification> = (
|
|
27
|
+
config: Readonly<{
|
|
28
|
+
channel: RpcSubscriptionsChannel<unknown, unknown>;
|
|
29
|
+
request: RpcRequest;
|
|
30
|
+
signal: AbortSignal;
|
|
31
|
+
}>,
|
|
32
|
+
) => Promise<DataPublisher<RpcSubscriptionsTransportDataEvents<TNotification>>>;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* This type allows an {@link RpcSubscriptionsApi} to describe how a particular subscription should
|
|
36
|
+
* be issued to the JSON RPC server.
|
|
37
|
+
*
|
|
38
|
+
* Given a function that was called on a {@link RpcSubscriptions}, this object exposes an `execute`
|
|
39
|
+
* function that dictates which subscription request will be sent, how the underlying transport will
|
|
40
|
+
* be used, and how the notifications will be transformed.
|
|
41
|
+
*
|
|
42
|
+
* This function accepts a {@link RpcSubscriptionsChannel} and an `AbortSignal` and asynchronously
|
|
43
|
+
* returns a {@link DataPublisher}. This gives us the opportunity to:
|
|
44
|
+
*
|
|
45
|
+
* - define the `payload` from the requested method name and parameters before passing it to the
|
|
46
|
+
* channel.
|
|
47
|
+
* - call the underlying channel zero, one or multiple times depending on the use-case (e.g.
|
|
48
|
+
* caching or coalescing multiple subscriptions).
|
|
49
|
+
* - transform the notification from the JSON RPC server, in case it does not match the
|
|
50
|
+
* `TNotification` specified by the
|
|
51
|
+
* {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest<TNotification>} emitted
|
|
52
|
+
* from the publisher returned.
|
|
53
|
+
*/
|
|
54
|
+
export type RpcSubscriptionsPlan<TNotification> = Readonly<{
|
|
55
|
+
/**
|
|
56
|
+
* This method may be called with a newly-opened channel or a pre-established channel.
|
|
57
|
+
*/
|
|
58
|
+
execute: (
|
|
59
|
+
config: Readonly<{
|
|
60
|
+
channel: RpcSubscriptionsChannel<unknown, unknown>;
|
|
61
|
+
signal: AbortSignal;
|
|
62
|
+
}>,
|
|
63
|
+
) => Promise<DataPublisher<RpcSubscriptionsTransportDataEvents<TNotification>>>;
|
|
64
|
+
/**
|
|
65
|
+
* This request is used to uniquely identify the subscription.
|
|
66
|
+
* It typically comes from the method name and parameters of the subscription call,
|
|
67
|
+
* after potentially being transformed by the RPC Subscriptions API.
|
|
68
|
+
*/
|
|
69
|
+
request: RpcRequest;
|
|
70
|
+
}>;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* For each of `TRpcSubscriptionsMethods`, this object exposes a method with the same name that maps
|
|
74
|
+
* between its input arguments and a
|
|
75
|
+
* {@link RpcSubscriptionsPlan | RpcSubscriptionsPlan<TNotification>} that implements the execution
|
|
76
|
+
* of a JSON RPC subscription for `TNotifications`.
|
|
77
|
+
*/
|
|
78
|
+
export type RpcSubscriptionsApi<TRpcSubscriptionMethods> = {
|
|
79
|
+
[MethodName in keyof TRpcSubscriptionMethods]: RpcSubscriptionsReturnTypeMapper<
|
|
80
|
+
TRpcSubscriptionMethods[MethodName]
|
|
81
|
+
>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
type RpcSubscriptionsReturnTypeMapper<TRpcMethod> = TRpcMethod extends Callable
|
|
85
|
+
? (...rawParams: unknown[]) => RpcSubscriptionsPlan<ReturnType<TRpcMethod>>
|
|
86
|
+
: never;
|
|
87
|
+
|
|
88
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
89
|
+
type RpcSubscriptionsApiMethod = (...args: any) => any;
|
|
90
|
+
export interface RpcSubscriptionsApiMethods {
|
|
91
|
+
[methodName: string]: RpcSubscriptionsApiMethod;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Creates a JavaScript proxy that converts _any_ function call called on it to a
|
|
96
|
+
* {@link RpcSubscriptionsPlan} by creating an `execute` function that:
|
|
97
|
+
*
|
|
98
|
+
* - calls the supplied {@link RpcSubscriptionsApiConfig.planExecutor} with a JSON RPC v2 payload
|
|
99
|
+
* object with the requested `methodName` and `params` properties, optionally transformed by
|
|
100
|
+
* {@link RpcSubscriptionsApiConfig.requestTransformer}.
|
|
101
|
+
*
|
|
102
|
+
* @example
|
|
103
|
+
* ```ts
|
|
104
|
+
* // For example, given this `RpcSubscriptionsApi`:
|
|
105
|
+
* const rpcSubscriptionsApi = createJsonRpcSubscriptionsApi({
|
|
106
|
+
* async planExecutor({ channel, request }) {
|
|
107
|
+
* await channel.send(request);
|
|
108
|
+
* return {
|
|
109
|
+
* ...channel,
|
|
110
|
+
* on(type, listener, options) {
|
|
111
|
+
* if (type !== 'message') {
|
|
112
|
+
* return channel.on(type, listener, options);
|
|
113
|
+
* }
|
|
114
|
+
* return channel.on(
|
|
115
|
+
* 'message',
|
|
116
|
+
* function resultGettingListener(message) {
|
|
117
|
+
* listener(message.result);
|
|
118
|
+
* },
|
|
119
|
+
* options,
|
|
120
|
+
* );
|
|
121
|
+
* }
|
|
122
|
+
* }
|
|
123
|
+
* },
|
|
124
|
+
* requestTransformer: (...rawParams) => rawParams.reverse(),
|
|
125
|
+
* });
|
|
126
|
+
*
|
|
127
|
+
* // ...the following function call:
|
|
128
|
+
* rpcSubscriptionsApi.foo('bar', { baz: 'bat' });
|
|
129
|
+
*
|
|
130
|
+
* // ...will produce a `RpcSubscriptionsPlan` that:
|
|
131
|
+
* // - Uses the following payload: { id: 1, jsonrpc: '2.0', method: 'foo', params: [{ baz: 'bat' }, 'bar'] }.
|
|
132
|
+
* // - Emits the "result" property of each RPC Subscriptions message.
|
|
133
|
+
* ```
|
|
134
|
+
*/
|
|
135
|
+
export function createRpcSubscriptionsApi<TRpcSubscriptionsApiMethods extends RpcSubscriptionsApiMethods>(
|
|
136
|
+
config: RpcSubscriptionsApiConfig<TRpcSubscriptionsApiMethods>,
|
|
137
|
+
): RpcSubscriptionsApi<TRpcSubscriptionsApiMethods> {
|
|
138
|
+
return new Proxy({} as RpcSubscriptionsApi<TRpcSubscriptionsApiMethods>, {
|
|
139
|
+
defineProperty() {
|
|
140
|
+
return false;
|
|
141
|
+
},
|
|
142
|
+
deleteProperty() {
|
|
143
|
+
return false;
|
|
144
|
+
},
|
|
145
|
+
get<TNotificationName extends keyof RpcSubscriptionsApi<TRpcSubscriptionsApiMethods>>(
|
|
146
|
+
...args: Parameters<NonNullable<ProxyHandler<RpcSubscriptionsApi<TRpcSubscriptionsApiMethods>>['get']>>
|
|
147
|
+
) {
|
|
148
|
+
const [_, p] = args;
|
|
149
|
+
const methodName = p.toString() as keyof TRpcSubscriptionsApiMethods as string;
|
|
150
|
+
return function (
|
|
151
|
+
...params: Parameters<
|
|
152
|
+
TRpcSubscriptionsApiMethods[TNotificationName] extends CallableFunction
|
|
153
|
+
? TRpcSubscriptionsApiMethods[TNotificationName]
|
|
154
|
+
: never
|
|
155
|
+
>
|
|
156
|
+
): RpcSubscriptionsPlan<ReturnType<TRpcSubscriptionsApiMethods[TNotificationName]>> {
|
|
157
|
+
const rawRequest = { methodName, params };
|
|
158
|
+
const request = config.requestTransformer ? config.requestTransformer(rawRequest) : rawRequest;
|
|
159
|
+
return {
|
|
160
|
+
execute(planConfig) {
|
|
161
|
+
return config.planExecutor({ ...planConfig, request });
|
|
162
|
+
},
|
|
163
|
+
request,
|
|
164
|
+
};
|
|
165
|
+
};
|
|
166
|
+
},
|
|
167
|
+
});
|
|
168
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import {
|
|
2
|
+
SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CLOSED_BEFORE_MESSAGE_BUFFERED,
|
|
3
|
+
SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED,
|
|
4
|
+
SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_FAILED_TO_CONNECT,
|
|
5
|
+
SolanaError,
|
|
6
|
+
} from '@solana/errors';
|
|
7
|
+
import { DataPublisher } from '@solana/subscribable';
|
|
8
|
+
|
|
9
|
+
type RpcSubscriptionsChannelSolanaErrorCode =
|
|
10
|
+
| typeof SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CLOSED_BEFORE_MESSAGE_BUFFERED
|
|
11
|
+
| typeof SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_CONNECTION_CLOSED
|
|
12
|
+
| typeof SOLANA_ERROR__RPC_SUBSCRIPTIONS__CHANNEL_FAILED_TO_CONNECT;
|
|
13
|
+
|
|
14
|
+
export type RpcSubscriptionChannelEvents<TInboundMessage> = {
|
|
15
|
+
/**
|
|
16
|
+
* Fires when the channel closes unexpectedly.
|
|
17
|
+
* @eventProperty
|
|
18
|
+
*/
|
|
19
|
+
error: SolanaError<RpcSubscriptionsChannelSolanaErrorCode>;
|
|
20
|
+
/**
|
|
21
|
+
* Fires on every message received from the remote end.
|
|
22
|
+
* @eventProperty
|
|
23
|
+
*/
|
|
24
|
+
message: TInboundMessage;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A {@link DataPublisher} on which you can subscribe to events of type
|
|
29
|
+
* {@link RpcSubscriptionChannelEvents | RpcSubscriptionChannelEvents<TInboundMessage>}.
|
|
30
|
+
* Additionally, you can use this object to send messages of type `TOutboundMessage` back to the
|
|
31
|
+
* remote end by calling its {@link RpcSubscriptionsChannel.send | `send(message)`} method.
|
|
32
|
+
*/
|
|
33
|
+
export interface RpcSubscriptionsChannel<TOutboundMessage, TInboundMessage> extends DataPublisher<
|
|
34
|
+
RpcSubscriptionChannelEvents<TInboundMessage>
|
|
35
|
+
> {
|
|
36
|
+
send(message: TOutboundMessage): Promise<void>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* A channel creator is a function that accepts an `AbortSignal`, returns a new
|
|
41
|
+
* {@link RpcSubscriptionsChannel}, and tears down the channel when the abort signal fires.
|
|
42
|
+
*/
|
|
43
|
+
export type RpcSubscriptionsChannelCreator<TOutboundMessage, TInboundMessage> = (
|
|
44
|
+
config: Readonly<{
|
|
45
|
+
abortSignal: AbortSignal;
|
|
46
|
+
}>,
|
|
47
|
+
) => Promise<RpcSubscriptionsChannel<TOutboundMessage, TInboundMessage>>;
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Given a channel with inbound messages of type `T` and a function of type `T => U`, returns a new
|
|
51
|
+
* channel with inbound messages of type `U`.
|
|
52
|
+
*
|
|
53
|
+
* Note that this only affects messages of type `"message"` and thus, does not affect incoming error
|
|
54
|
+
* messages.
|
|
55
|
+
*
|
|
56
|
+
* @example Parsing incoming JSON messages
|
|
57
|
+
* ```ts
|
|
58
|
+
* const transformedChannel = transformChannelInboundMessages(channel, JSON.parse);
|
|
59
|
+
* ```
|
|
60
|
+
*/
|
|
61
|
+
export function transformChannelInboundMessages<TOutboundMessage, TNewInboundMessage, TInboundMessage>(
|
|
62
|
+
channel: RpcSubscriptionsChannel<TOutboundMessage, TInboundMessage>,
|
|
63
|
+
transform: (message: TInboundMessage) => TNewInboundMessage,
|
|
64
|
+
): RpcSubscriptionsChannel<TOutboundMessage, TNewInboundMessage> {
|
|
65
|
+
return Object.freeze<RpcSubscriptionsChannel<TOutboundMessage, TNewInboundMessage>>({
|
|
66
|
+
...channel,
|
|
67
|
+
on(type, subscriber, options) {
|
|
68
|
+
if (type !== 'message') {
|
|
69
|
+
return channel.on(
|
|
70
|
+
type,
|
|
71
|
+
subscriber as (data: RpcSubscriptionChannelEvents<TInboundMessage>[typeof type]) => void,
|
|
72
|
+
options,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return channel.on(
|
|
76
|
+
'message',
|
|
77
|
+
message => (subscriber as (data: TNewInboundMessage) => void)(transform(message)),
|
|
78
|
+
options,
|
|
79
|
+
);
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Given a channel with outbound messages of type `T` and a function of type `U => T`, returns a new
|
|
86
|
+
* channel with outbound messages of type `U`.
|
|
87
|
+
*
|
|
88
|
+
* @example Stringifying JSON messages before sending them over the wire
|
|
89
|
+
* ```ts
|
|
90
|
+
* const transformedChannel = transformChannelOutboundMessages(channel, JSON.stringify);
|
|
91
|
+
* ```
|
|
92
|
+
*/
|
|
93
|
+
export function transformChannelOutboundMessages<TNewOutboundMessage, TOutboundMessage, TInboundMessage>(
|
|
94
|
+
channel: RpcSubscriptionsChannel<TOutboundMessage, TInboundMessage>,
|
|
95
|
+
transform: (message: TNewOutboundMessage) => TOutboundMessage,
|
|
96
|
+
): RpcSubscriptionsChannel<TNewOutboundMessage, TInboundMessage> {
|
|
97
|
+
return Object.freeze<RpcSubscriptionsChannel<TNewOutboundMessage, TInboundMessage>>({
|
|
98
|
+
...channel,
|
|
99
|
+
send: message => channel.send(transform(message)),
|
|
100
|
+
});
|
|
101
|
+
}
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
import {
|
|
2
|
+
getSolanaErrorFromJsonRpcError,
|
|
3
|
+
SOLANA_ERROR__INVARIANT_VIOLATION__DATA_PUBLISHER_CHANNEL_UNIMPLEMENTED,
|
|
4
|
+
SOLANA_ERROR__RPC_SUBSCRIPTIONS__EXPECTED_SERVER_SUBSCRIPTION_ID,
|
|
5
|
+
SolanaError,
|
|
6
|
+
} from '@solana/errors';
|
|
7
|
+
import { AbortController } from '@solana/event-target-impl';
|
|
8
|
+
import { safeRace } from '@solana/promises';
|
|
9
|
+
import { createRpcMessage, RpcRequest, RpcResponseData, RpcResponseTransformer } from '@solana/rpc-spec-types';
|
|
10
|
+
import { DataPublisher } from '@solana/subscribable';
|
|
11
|
+
import { demultiplexDataPublisher } from '@solana/subscribable';
|
|
12
|
+
|
|
13
|
+
import { RpcSubscriptionChannelEvents } from './rpc-subscriptions-channel';
|
|
14
|
+
import { RpcSubscriptionsChannel } from './rpc-subscriptions-channel';
|
|
15
|
+
|
|
16
|
+
type Config<TNotification> = Readonly<{
|
|
17
|
+
channel: RpcSubscriptionsChannel<unknown, RpcNotification<TNotification> | RpcResponseData<RpcSubscriptionId>>;
|
|
18
|
+
responseTransformer?: RpcResponseTransformer;
|
|
19
|
+
signal: AbortSignal;
|
|
20
|
+
subscribeRequest: RpcRequest;
|
|
21
|
+
unsubscribeMethodName: string;
|
|
22
|
+
}>;
|
|
23
|
+
|
|
24
|
+
type RpcNotification<TNotification> = Readonly<{
|
|
25
|
+
method: string;
|
|
26
|
+
params: Readonly<{
|
|
27
|
+
result: TNotification;
|
|
28
|
+
subscription: number;
|
|
29
|
+
}>;
|
|
30
|
+
}>;
|
|
31
|
+
|
|
32
|
+
type RpcSubscriptionId = number;
|
|
33
|
+
|
|
34
|
+
type RpcSubscriptionNotificationEvents<TNotification> = Omit<RpcSubscriptionChannelEvents<TNotification>, 'message'> & {
|
|
35
|
+
notification: TNotification;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const subscriberCountBySubscriptionIdByChannel = new WeakMap<WeakKey, Record<number, number>>();
|
|
39
|
+
function decrementSubscriberCountAndReturnNewCount(channel: WeakKey, subscriptionId?: number): number | undefined {
|
|
40
|
+
return augmentSubscriberCountAndReturnNewCount(-1, channel, subscriptionId);
|
|
41
|
+
}
|
|
42
|
+
function incrementSubscriberCount(channel: WeakKey, subscriptionId?: number): void {
|
|
43
|
+
augmentSubscriberCountAndReturnNewCount(1, channel, subscriptionId);
|
|
44
|
+
}
|
|
45
|
+
function getSubscriberCountBySubscriptionIdForChannel(channel: WeakKey): Record<number, number> {
|
|
46
|
+
let subscriberCountBySubscriptionId = subscriberCountBySubscriptionIdByChannel.get(channel);
|
|
47
|
+
if (!subscriberCountBySubscriptionId) {
|
|
48
|
+
subscriberCountBySubscriptionIdByChannel.set(channel, (subscriberCountBySubscriptionId = {}));
|
|
49
|
+
}
|
|
50
|
+
return subscriberCountBySubscriptionId;
|
|
51
|
+
}
|
|
52
|
+
function augmentSubscriberCountAndReturnNewCount(
|
|
53
|
+
amount: -1 | 1,
|
|
54
|
+
channel: WeakKey,
|
|
55
|
+
subscriptionId?: number,
|
|
56
|
+
): number | undefined {
|
|
57
|
+
if (subscriptionId === undefined) {
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
const subscriberCountBySubscriptionId = getSubscriberCountBySubscriptionIdForChannel(channel);
|
|
61
|
+
if (!subscriberCountBySubscriptionId[subscriptionId] && amount > 0) {
|
|
62
|
+
subscriberCountBySubscriptionId[subscriptionId] = 0;
|
|
63
|
+
}
|
|
64
|
+
const newCount = amount + subscriberCountBySubscriptionId[subscriptionId];
|
|
65
|
+
if (newCount <= 0) {
|
|
66
|
+
delete subscriberCountBySubscriptionId[subscriptionId];
|
|
67
|
+
} else {
|
|
68
|
+
subscriberCountBySubscriptionId[subscriptionId] = newCount;
|
|
69
|
+
}
|
|
70
|
+
return newCount;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const cache = new WeakMap();
|
|
74
|
+
function getMemoizedDemultiplexedNotificationPublisherFromChannelAndResponseTransformer<TNotification>(
|
|
75
|
+
channel: RpcSubscriptionsChannel<unknown, RpcNotification<TNotification>>,
|
|
76
|
+
subscribeRequest: RpcRequest,
|
|
77
|
+
responseTransformer?: RpcResponseTransformer,
|
|
78
|
+
): DataPublisher<{
|
|
79
|
+
[channelName: `notification:${number}`]: TNotification;
|
|
80
|
+
}> {
|
|
81
|
+
let publisherByResponseTransformer = cache.get(channel);
|
|
82
|
+
if (!publisherByResponseTransformer) {
|
|
83
|
+
cache.set(channel, (publisherByResponseTransformer = new WeakMap()));
|
|
84
|
+
}
|
|
85
|
+
const responseTransformerKey = responseTransformer ?? channel;
|
|
86
|
+
let publisher = publisherByResponseTransformer.get(responseTransformerKey);
|
|
87
|
+
if (!publisher) {
|
|
88
|
+
publisherByResponseTransformer.set(
|
|
89
|
+
responseTransformerKey,
|
|
90
|
+
(publisher = demultiplexDataPublisher(channel, 'message', rawMessage => {
|
|
91
|
+
const message = rawMessage as RpcNotification<unknown> | RpcResponseData<unknown>;
|
|
92
|
+
if (!('method' in message)) {
|
|
93
|
+
return;
|
|
94
|
+
}
|
|
95
|
+
const transformedNotification = responseTransformer
|
|
96
|
+
? responseTransformer(message.params.result, subscribeRequest)
|
|
97
|
+
: message.params.result;
|
|
98
|
+
return [`notification:${message.params.subscription}`, transformedNotification];
|
|
99
|
+
})),
|
|
100
|
+
);
|
|
101
|
+
}
|
|
102
|
+
return publisher;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Given a channel, this function executes the particular subscription plan required by the Solana
|
|
107
|
+
* JSON RPC Subscriptions API.
|
|
108
|
+
*
|
|
109
|
+
* @param config
|
|
110
|
+
*
|
|
111
|
+
* 1. Calls the `subscribeRequest` on the remote RPC
|
|
112
|
+
* 2. Waits for a response containing the subscription id
|
|
113
|
+
* 3. Returns a {@link DataPublisher} that publishes notifications related to that subscriptions id,
|
|
114
|
+
* filtering out all others
|
|
115
|
+
* 4. Calls the `unsubscribeMethodName` on the remote RPC when the abort signal is fired.
|
|
116
|
+
*/
|
|
117
|
+
export async function executeRpcPubSubSubscriptionPlan<TNotification>({
|
|
118
|
+
channel,
|
|
119
|
+
responseTransformer,
|
|
120
|
+
signal,
|
|
121
|
+
subscribeRequest,
|
|
122
|
+
unsubscribeMethodName,
|
|
123
|
+
}: Config<TNotification>): Promise<DataPublisher<RpcSubscriptionNotificationEvents<TNotification>>> {
|
|
124
|
+
let subscriptionId: number | undefined;
|
|
125
|
+
channel.on(
|
|
126
|
+
'error',
|
|
127
|
+
() => {
|
|
128
|
+
// An error on the channel indicates that the subscriptions are dead.
|
|
129
|
+
// There is no longer any sense hanging on to subscription ids.
|
|
130
|
+
// Erasing it here will prevent the unsubscribe code from running.
|
|
131
|
+
subscriptionId = undefined;
|
|
132
|
+
subscriberCountBySubscriptionIdByChannel.delete(channel);
|
|
133
|
+
},
|
|
134
|
+
{ signal },
|
|
135
|
+
);
|
|
136
|
+
/**
|
|
137
|
+
* STEP 1
|
|
138
|
+
* Create a promise that rejects if this subscription is aborted and sends
|
|
139
|
+
* the unsubscribe message if the subscription is active at that time.
|
|
140
|
+
*/
|
|
141
|
+
const abortPromise = new Promise<never>((_, reject) => {
|
|
142
|
+
function handleAbort(this: AbortSignal) {
|
|
143
|
+
/**
|
|
144
|
+
* Because of https://github.com/solana-labs/solana/pull/18943, two subscriptions for
|
|
145
|
+
* materially the same notification will be coalesced on the server. This means they
|
|
146
|
+
* will be assigned the same subscription id, and will occupy one subscription slot. We
|
|
147
|
+
* must be careful not to send the unsubscribe message until the last subscriber aborts.
|
|
148
|
+
*/
|
|
149
|
+
if (decrementSubscriberCountAndReturnNewCount(channel, subscriptionId) === 0) {
|
|
150
|
+
const unsubscribePayload = createRpcMessage({
|
|
151
|
+
methodName: unsubscribeMethodName,
|
|
152
|
+
params: [subscriptionId],
|
|
153
|
+
});
|
|
154
|
+
subscriptionId = undefined;
|
|
155
|
+
channel.send(unsubscribePayload).catch(() => {});
|
|
156
|
+
}
|
|
157
|
+
// eslint-disable-next-line @typescript-eslint/prefer-promise-reject-errors
|
|
158
|
+
reject(this.reason);
|
|
159
|
+
}
|
|
160
|
+
if (signal.aborted) {
|
|
161
|
+
handleAbort.call(signal);
|
|
162
|
+
} else {
|
|
163
|
+
signal.addEventListener('abort', handleAbort);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
166
|
+
/**
|
|
167
|
+
* STEP 2
|
|
168
|
+
* Send the subscription request.
|
|
169
|
+
*/
|
|
170
|
+
const subscribePayload = createRpcMessage(subscribeRequest);
|
|
171
|
+
await channel.send(subscribePayload);
|
|
172
|
+
/**
|
|
173
|
+
* STEP 3
|
|
174
|
+
* Wait for the acknowledgement from the server with the subscription id.
|
|
175
|
+
*/
|
|
176
|
+
const subscriptionIdPromise = new Promise<RpcSubscriptionId>((resolve, reject) => {
|
|
177
|
+
const abortController = new AbortController();
|
|
178
|
+
signal.addEventListener('abort', abortController.abort.bind(abortController));
|
|
179
|
+
const options = { signal: abortController.signal } as const;
|
|
180
|
+
channel.on(
|
|
181
|
+
'error',
|
|
182
|
+
err => {
|
|
183
|
+
abortController.abort();
|
|
184
|
+
reject(err);
|
|
185
|
+
},
|
|
186
|
+
options,
|
|
187
|
+
);
|
|
188
|
+
channel.on(
|
|
189
|
+
'message',
|
|
190
|
+
message => {
|
|
191
|
+
if (message && typeof message === 'object' && 'id' in message && message.id === subscribePayload.id) {
|
|
192
|
+
abortController.abort();
|
|
193
|
+
if ('error' in message) {
|
|
194
|
+
reject(getSolanaErrorFromJsonRpcError(message.error));
|
|
195
|
+
} else {
|
|
196
|
+
resolve(message.result);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
},
|
|
200
|
+
options,
|
|
201
|
+
);
|
|
202
|
+
});
|
|
203
|
+
subscriptionId = await safeRace([abortPromise, subscriptionIdPromise]);
|
|
204
|
+
if (subscriptionId == null) {
|
|
205
|
+
throw new SolanaError(SOLANA_ERROR__RPC_SUBSCRIPTIONS__EXPECTED_SERVER_SUBSCRIPTION_ID);
|
|
206
|
+
}
|
|
207
|
+
incrementSubscriberCount(channel, subscriptionId);
|
|
208
|
+
/**
|
|
209
|
+
* STEP 4
|
|
210
|
+
* Filter out notifications unrelated to this subscription.
|
|
211
|
+
*/
|
|
212
|
+
const notificationPublisher = getMemoizedDemultiplexedNotificationPublisherFromChannelAndResponseTransformer(
|
|
213
|
+
channel,
|
|
214
|
+
subscribeRequest,
|
|
215
|
+
responseTransformer,
|
|
216
|
+
);
|
|
217
|
+
const notificationKey = `notification:${subscriptionId}` as const;
|
|
218
|
+
return {
|
|
219
|
+
on(type, listener, options) {
|
|
220
|
+
switch (type) {
|
|
221
|
+
case 'notification':
|
|
222
|
+
return notificationPublisher.on(
|
|
223
|
+
notificationKey,
|
|
224
|
+
listener as (data: RpcSubscriptionNotificationEvents<TNotification>['notification']) => void,
|
|
225
|
+
options,
|
|
226
|
+
);
|
|
227
|
+
case 'error':
|
|
228
|
+
return channel.on(
|
|
229
|
+
'error',
|
|
230
|
+
listener as (data: RpcSubscriptionNotificationEvents<TNotification>['error']) => void,
|
|
231
|
+
options,
|
|
232
|
+
);
|
|
233
|
+
default:
|
|
234
|
+
throw new SolanaError(SOLANA_ERROR__INVARIANT_VIOLATION__DATA_PUBLISHER_CHANNEL_UNIMPLEMENTED, {
|
|
235
|
+
channelName: type,
|
|
236
|
+
supportedChannelNames: ['notification', 'error'],
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
},
|
|
240
|
+
};
|
|
241
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pending subscriptions are the result of calling a supported method on a {@link RpcSubscriptions}
|
|
3
|
+
* object. They encapsulate all of the information necessary to make the subscription without
|
|
4
|
+
* actually making it.
|
|
5
|
+
*
|
|
6
|
+
* Calling the {@link PendingRpcSubscriptionsRequest.subscribe | `subscribe(options)`} method on a
|
|
7
|
+
* {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest<TNotification>} will
|
|
8
|
+
* trigger the subscription and return a promise for an async iterable that vends `TNotifications`.
|
|
9
|
+
*/
|
|
10
|
+
export type PendingRpcSubscriptionsRequest<TNotification> = {
|
|
11
|
+
subscribe(options: RpcSubscribeOptions): Promise<AsyncIterable<TNotification>>;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type RpcSubscribeOptions = Readonly<{
|
|
15
|
+
/** An `AbortSignal` to fire when you want to unsubscribe */
|
|
16
|
+
abortSignal: AbortSignal;
|
|
17
|
+
}>;
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { SolanaError } from '@solana/errors';
|
|
2
|
+
import { DataPublisher } from '@solana/subscribable';
|
|
3
|
+
|
|
4
|
+
import { RpcSubscriptionsPlan } from './rpc-subscriptions-api';
|
|
5
|
+
|
|
6
|
+
export type RpcSubscriptionsTransportDataEvents<TNotification> = {
|
|
7
|
+
/**
|
|
8
|
+
* Fires when there is an error with the subscription or the channel.
|
|
9
|
+
* @eventProperty
|
|
10
|
+
*/
|
|
11
|
+
error: SolanaError;
|
|
12
|
+
/**
|
|
13
|
+
* Fires on every notification received.
|
|
14
|
+
* @eventProperty
|
|
15
|
+
*/
|
|
16
|
+
notification: TNotification;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
interface RpcSubscriptionsTransportConfig<TNotification> extends RpcSubscriptionsPlan<TNotification> {
|
|
20
|
+
/** An `AbortSignal` to fire when you want to unsubscribe */
|
|
21
|
+
signal: AbortSignal;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* A function that can act as a transport for a {@link RpcSubscriptions}. It need only return a
|
|
26
|
+
* promise for a {@link DataPublisher} given the supplied config.
|
|
27
|
+
*/
|
|
28
|
+
export interface RpcSubscriptionsTransport {
|
|
29
|
+
<TNotification>(
|
|
30
|
+
config: RpcSubscriptionsTransportConfig<TNotification>,
|
|
31
|
+
): Promise<DataPublisher<RpcSubscriptionsTransportDataEvents<TNotification>>>;
|
|
32
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN, SolanaError } from '@solana/errors';
|
|
2
|
+
import { Callable, Flatten, OverloadImplementations, UnionToIntersection } from '@solana/rpc-spec-types';
|
|
3
|
+
import { createAsyncIterableFromDataPublisher } from '@solana/subscribable';
|
|
4
|
+
|
|
5
|
+
import { RpcSubscriptionsApi, RpcSubscriptionsPlan } from './rpc-subscriptions-api';
|
|
6
|
+
import { PendingRpcSubscriptionsRequest, RpcSubscribeOptions } from './rpc-subscriptions-request';
|
|
7
|
+
import { RpcSubscriptionsTransport } from './rpc-subscriptions-transport';
|
|
8
|
+
|
|
9
|
+
export type RpcSubscriptionsConfig<TRpcMethods> = Readonly<{
|
|
10
|
+
api: RpcSubscriptionsApi<TRpcMethods>;
|
|
11
|
+
transport: RpcSubscriptionsTransport;
|
|
12
|
+
}>;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* An object that exposes all of the functions described by `TRpcSubscriptionsMethods`.
|
|
16
|
+
*
|
|
17
|
+
* Calling each method returns a
|
|
18
|
+
* {@link PendingRpcSubscriptionsRequest | PendingRpcSubscriptionsRequest<TNotification>} where
|
|
19
|
+
* `TNotification` is that method's notification type.
|
|
20
|
+
*/
|
|
21
|
+
export type RpcSubscriptions<TRpcSubscriptionsMethods> = {
|
|
22
|
+
[TMethodName in keyof TRpcSubscriptionsMethods]: PendingRpcSubscriptionsRequestBuilder<
|
|
23
|
+
OverloadImplementations<TRpcSubscriptionsMethods, TMethodName>
|
|
24
|
+
>;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
type PendingRpcSubscriptionsRequestBuilder<TSubscriptionMethodImplementations> = UnionToIntersection<
|
|
28
|
+
Flatten<{
|
|
29
|
+
[P in keyof TSubscriptionMethodImplementations]: PendingRpcSubscriptionsRequestReturnTypeMapper<
|
|
30
|
+
TSubscriptionMethodImplementations[P]
|
|
31
|
+
>;
|
|
32
|
+
}>
|
|
33
|
+
>;
|
|
34
|
+
|
|
35
|
+
type PendingRpcSubscriptionsRequestReturnTypeMapper<TSubscriptionMethodImplementation> =
|
|
36
|
+
// Check that this property of the TRpcSubscriptionMethods interface is, in fact, a function.
|
|
37
|
+
TSubscriptionMethodImplementation extends Callable
|
|
38
|
+
? (
|
|
39
|
+
...args: Parameters<TSubscriptionMethodImplementation>
|
|
40
|
+
) => PendingRpcSubscriptionsRequest<ReturnType<TSubscriptionMethodImplementation>>
|
|
41
|
+
: never;
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Creates a {@link RpcSubscriptions} instance given a
|
|
45
|
+
* {@link RpcSubscriptionsApi | RpcSubscriptionsApi<TRpcSubscriptionsApiMethods>} and a
|
|
46
|
+
* {@link RpcSubscriptionsTransport} capable of fulfilling them.
|
|
47
|
+
*/
|
|
48
|
+
export function createSubscriptionRpc<TRpcSubscriptionsApiMethods>(
|
|
49
|
+
rpcConfig: RpcSubscriptionsConfig<TRpcSubscriptionsApiMethods>,
|
|
50
|
+
): RpcSubscriptions<TRpcSubscriptionsApiMethods> {
|
|
51
|
+
return new Proxy(rpcConfig.api, {
|
|
52
|
+
defineProperty() {
|
|
53
|
+
return false;
|
|
54
|
+
},
|
|
55
|
+
deleteProperty() {
|
|
56
|
+
return false;
|
|
57
|
+
},
|
|
58
|
+
get(target, p, receiver) {
|
|
59
|
+
if (p === 'then') {
|
|
60
|
+
return undefined;
|
|
61
|
+
}
|
|
62
|
+
return function (...rawParams: unknown[]) {
|
|
63
|
+
const notificationName = p.toString();
|
|
64
|
+
const createRpcSubscriptionPlan = Reflect.get(target, notificationName, receiver);
|
|
65
|
+
if (!createRpcSubscriptionPlan) {
|
|
66
|
+
throw new SolanaError(SOLANA_ERROR__RPC_SUBSCRIPTIONS__CANNOT_CREATE_SUBSCRIPTION_PLAN, {
|
|
67
|
+
notificationName,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
const subscriptionPlan = createRpcSubscriptionPlan(...rawParams);
|
|
71
|
+
return createPendingRpcSubscription(rpcConfig.transport, subscriptionPlan);
|
|
72
|
+
};
|
|
73
|
+
},
|
|
74
|
+
}) as RpcSubscriptions<TRpcSubscriptionsApiMethods>;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function createPendingRpcSubscription<TNotification>(
|
|
78
|
+
transport: RpcSubscriptionsTransport,
|
|
79
|
+
subscriptionsPlan: RpcSubscriptionsPlan<TNotification>,
|
|
80
|
+
): PendingRpcSubscriptionsRequest<TNotification> {
|
|
81
|
+
return {
|
|
82
|
+
async subscribe({ abortSignal }: RpcSubscribeOptions): Promise<AsyncIterable<TNotification>> {
|
|
83
|
+
const notificationsDataPublisher = await transport({
|
|
84
|
+
signal: abortSignal,
|
|
85
|
+
...subscriptionsPlan,
|
|
86
|
+
});
|
|
87
|
+
return createAsyncIterableFromDataPublisher<TNotification>({
|
|
88
|
+
abortSignal,
|
|
89
|
+
dataChannelName: 'notification',
|
|
90
|
+
dataPublisher: notificationsDataPublisher,
|
|
91
|
+
errorChannelName: 'error',
|
|
92
|
+
});
|
|
93
|
+
},
|
|
94
|
+
};
|
|
95
|
+
}
|