Press n or j to go to the next uncovered block, b, p or k for the previous block.
| 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 | 218x 218x 218x 218x 218x 218x 508x 501x 501x 503x 2x 1x 5x 5x 501x 501x 1x 508x 508x 508x 508x 507x 507x 3x 504x 504x 501x 3x 2x 1x 12x 3x 3x 508x | import { makePromiseKit } from '@endo/promise-kit';
import { makeCounter, stringify } from '@metamask/kernel-utils';
import type { PromiseCallbacks } from '@metamask/kernel-utils';
import { Logger } from '@metamask/logger';
import { assert as assertStruct } from '@metamask/superstruct';
import { isJsonRpcFailure, isJsonRpcSuccess } from '@metamask/utils';
import type {
JsonRpcNotification,
JsonRpcRequest,
JsonRpcSuccess,
} from '@metamask/utils';
import type {
MethodSpec,
ExtractParams,
ExtractResult,
MethodSpecRecord,
ExtractNotification,
ExtractRequest,
} from './types.ts';
export type SendMessage = (
payload: JsonRpcRequest | JsonRpcNotification,
) => Promise<void>;
/**
* A typed JSON-RPC client that sends requests and notifications over a provided message channel.
*/
export class RpcClient<
// The class picks up its type from the `methods` argument,
// so using `any` in this constraint is safe.
// eslint-disable-next-line @typescript-eslint/no-explicit-any
Methods extends MethodSpecRecord<MethodSpec<string, any, any>>,
> {
readonly #methods: Methods;
readonly #prefix: string;
readonly #unresolvedMessages = new Map<string, PromiseCallbacks>();
readonly #messageCounter = makeCounter();
readonly #sendMessage: SendMessage;
readonly #logger: Logger;
/**
* Creates a new RpcClient instance.
*
* @param methods - The method specifications defining available RPC methods and their types.
* @param sendMessage - The function used to send JSON-RPC messages to the remote endpoint.
* @param prefix - A string prefix for generating unique message IDs.
* @param logger - The logger instance for debugging and error reporting.
*/
constructor(
methods: Methods,
sendMessage: SendMessage,
prefix: string,
logger: Logger = new Logger('rpc-client'),
) {
this.#methods = methods;
this.#sendMessage = sendMessage;
this.#prefix = prefix;
this.#logger = logger;
}
/**
* Sends a JSON-RPC request with the specified message ID and awaits the result.
*
* @param method - The RPC method name to call.
* @param params - The parameters to pass to the method.
* @param id - The unique message ID for correlating the request with its response.
* @returns A promise that resolves to the validated result of the RPC call.
*/
async #call<Method extends ExtractRequest<Methods>>(
method: Method,
params: ExtractParams<Method, Methods>,
id: string,
): Promise<ExtractResult<Method, Methods>> {
const response = await this.#createMessage(id, {
id,
jsonrpc: '2.0',
method,
params,
});
this.#assertResult(method, response.result);
return response.result;
}
/**
* Calls a JSON-RPC method and returns the result.
*
* @param method - The method to call.
* @param params - The parameters to pass to the method.
* @returns A promise that resolves to the result.
*/
async call<Method extends ExtractRequest<Methods>>(
method: Method,
params: ExtractParams<Method, Methods>,
): Promise<ExtractResult<Method, Methods>> {
return await this.#call(method, params, this.#nextMessageId());
}
/**
* Sends a JSON-RPC notification. Recall that we do not receive responses to notifications
* for any reason.
*
* @param method - The method to notify.
* @param params - The parameters to pass to the method.
*/
async notify<Method extends ExtractNotification<Methods>>(
method: Method,
params: ExtractParams<Method, Methods>,
): Promise<void> {
await this.#sendMessage({
jsonrpc: '2.0',
method,
params,
}).catch((error) =>
this.#logger.error(`Failed to send notification`, error),
);
}
/**
* Calls a JSON-RPC method and returns the message id and the result.
*
* @param method - The method to call.
* @param params - The parameters to pass to the method.
* @returns A promise that resolves to a tuple of the message id and the result.
*/
async callAndGetId<Method extends ExtractRequest<Methods>>(
method: Method,
params: ExtractParams<Method, Methods>,
): Promise<[string, ExtractResult<Method, Methods>]> {
const id = this.#nextMessageId();
return [id, await this.#call(method, params, id)];
}
/**
* Validates that a result matches the expected type defined in the method specification.
*
* @param method - The RPC method name used to look up the expected result type.
* @param result - The result value to validate against the method's result schema.
*/
#assertResult<Method extends ExtractRequest<Methods>>(
method: Method,
result: unknown,
): asserts result is ExtractResult<Method, Methods> {
try {
// @ts-expect-error: For unknown reasons, TypeScript fails to recognize that
// `Method` must be a key of `this.#methods`.
assertStruct(result, this.#methods[method].result);
} catch (error) {
throw new Error(`Invalid result: ${(error as Error).message}`);
}
}
/**
* Creates and sends a JSON-RPC request, returning a promise that resolves when a response is received.
*
* @param messageId - The unique ID to associate with the request for response correlation.
* @param payload - The JSON-RPC request payload to send.
* @returns A promise that resolves to the successful JSON-RPC response.
*/
async #createMessage(
messageId: string,
payload: JsonRpcRequest,
): Promise<JsonRpcSuccess> {
const { promise, reject, resolve } = makePromiseKit<JsonRpcSuccess>();
this.#unresolvedMessages.set(messageId, {
resolve: resolve as (value: unknown) => void,
reject,
});
await this.#sendMessage(payload);
return promise;
}
/**
* Handles a JSON-RPC response to a previously made method call.
*
* @param messageId - The id of the message to handle.
* @param response - The response to handle.
*/
handleResponse(messageId: string, response: unknown): void {
const requestCallbacks = this.#unresolvedMessages.get(messageId);
if (requestCallbacks === undefined) {
this.#logger.debug(
`Received response with unexpected id "${messageId}".`,
);
} else {
this.#unresolvedMessages.delete(messageId);
if (isJsonRpcSuccess(response)) {
requestCallbacks.resolve(response);
} else if (isJsonRpcFailure(response)) {
requestCallbacks.reject(response.error);
} else {
requestCallbacks.reject(
new Error(`Invalid JSON-RPC response: ${stringify(response)}`),
);
}
}
}
/**
* Rejects all unresolved messages with an error.
*
* @param error - The error to reject the messages with.
*/
rejectAll(error: Error): void {
for (const [messageId, promiseCallback] of this.#unresolvedMessages) {
promiseCallback?.reject(error);
this.#unresolvedMessages.delete(messageId);
}
}
/**
* Generates the next unique message ID by combining the prefix with an incrementing counter.
*
* @returns A unique string identifier for the next message.
*/
#nextMessageId(): string {
return `${this.#prefix}${this.#messageCounter()}`;
}
}
|