Resolve "Blockchain Adapter: Add Chaingraph Adapter"

This commit is contained in:
devperate
2025-08-25 03:57:00 +00:00
committed by Jim Hamill
parent c16f5e6fd0
commit dffa72d850
16 changed files with 3103 additions and 20 deletions
+465
View File
@@ -0,0 +1,465 @@
import { WebsocketClient } from './ws-client.js';
import { EventEmitter } from './event-emitter.js';
import { jsonToGraphQLQuery } from 'json-to-graphql-query';
import { z } from 'zod';
// The format of the querys accepted by 'request' and 'subscribe' functions.
export type GraphqlFetchQuery =
| Record<'query', unknown>
| Record<'mutation', unknown>;
export type GraphqlSubscriptionQuery = Record<'subscription', unknown>;
// Format for query variables.
export type GraphqlQueryVariables = Record<string, string | number>;
// Shape of the Connection Initialization Message chaingraph accepts.
type ConnectionInitMessage = {
type: 'connection_init';
// 'Record<never, never>' stand for empty payload.
payload: Record<never, never> | Record<'token', string>;
};
// Shape of the data query used for Requests and Subscriptions.
type DataRequestMessage = {
id: string;
type: 'start';
payload: {
queryId: string;
query: string;
variables?: GraphqlQueryVariables;
};
};
// Shape of the unsubscription query.
type UnsubscriptionMessage = {
id: string;
type: 'stop';
};
// Shape of a chaingraph response.
export type GraphqlRequestResponse = {
data: Record<string, unknown>;
};
// Shape of the subscription objects stored in our Map of active subscriptions.
type Subscriptions = {
queryId: string;
body: {
type: DataRequestMessage['type'];
payload: {
query: GraphqlSubscriptionQuery;
variables?: GraphqlQueryVariables;
};
};
};
export type GraphqlClientEventsPayload =
| Record<string, unknown>
| Error
| boolean;
export type GraphQLClientEvents<T extends string = string> = {
/**
* Emitted when data has been received from a subscription via websocket.
* Emitted when the socket has failed in some way.
*
* List of events emitted:
* 'isConnectionInitialized' : boolean emitted when graphql protocol handshake was accepted and websocket messaging started.
* 'error' : Error emitted when the websocket or chaingraph notify about an error.
* '<SubscriptionId>' : Record<string, unknown> emitted when server send a message that carries updated data of a subscription made.
*
* @eventProperty
*/
[K in T]: GraphqlClientEventsPayload;
};
export class GraphQLClient extends EventEmitter<GraphQLClientEvents> {
// Our websocket client implementation that will handle subscriptions.
private websocket: WebsocketClient;
// Track if the websocket connection has been initialized and handshake made.
private isWebSocketConnectionInitialized: boolean;
// Map that contain relative data to currently active subscriptions.
// This will allow us to re-subscribe in case of websocket disconnetion.
private activeSubscriptions: Map<
Subscriptions['queryId'],
Subscriptions['body']
> = new Map();
constructor(
public endpoint: string,
private token?: string,
) {
super();
this.isWebSocketConnectionInitialized = false;
// Defines the subprotocol to comunicate with a graphql server.
const options = {
protocols: 'graphql-ws',
};
// Adapt the given endpoint to setup a websocket comunication protocol.
const websocketHost = this.endpoint.includes('http')
? this.endpoint.replace('http', 'ws')
: this.endpoint.includes('https')
? this.endpoint.replace('https', 'wss')
: this.endpoint;
// Instantiate our WebSocket Client class.
this.websocket = new WebsocketClient(websocketHost, options);
// Subscribe to events emited by our websocket client.
this.websocket.on('isOpen', (isOpen: boolean) => {
// If WebSocket is open...
if (isOpen) {
this.handleOpenSocket.bind(this);
// return to prevent further code execution.
return;
}
// Otherwise update internal status to disconnected and emit.
this.isWebSocketConnectionInitialized = false;
this.emit(
'isConnectionInitialized',
this.isWebSocketConnectionInitialized,
);
});
this.websocket.on('message', this.handleMessageResponse.bind(this));
this.websocket.on('error', (error) => {
this.emit('error', new Error(`WebSocket error: ${error}`));
});
}
private async handleOpenSocket(): Promise<void> {
// Re-subscribe to the currently active subscriptions.
if (this.activeSubscriptions.size > 0) {
// Create a list that will carry the subscriptions to be made.
const subscriptionPromises = [];
for (const key of this.activeSubscriptions.keys()) {
const subscriptionBody = this.activeSubscriptions.get(key);
if (subscriptionBody)
subscriptionPromises.push(
async () =>
await this.subscribe(
key,
subscriptionBody.payload.query,
false,
subscriptionBody.payload.variables,
),
);
}
// Request the server to run all the subscriptions in parallel.
await Promise.all(subscriptionPromises);
}
}
/**
* Parse and handle websocket subscription messages done by the graphl server.
*/
private handleMessageResponse(event: MessageEvent): void {
// Validate the payload body.
const eventData = GraphQLClient.subscriptionResponseSchema.parse(
JSON.parse(event.data),
);
// If Graphql server notifies of error trying to establish connection.
if (eventData.type === 'connection_error') {
this.isWebSocketConnectionInitialized = false;
this.emit(
'error',
new Error(
`Error of connection. Code: ${eventData.payload.code}, Message: ${eventData.payload.message}`,
),
);
// Return to avoid further code execution.
return;
}
// If Graphql server notifies of successfully connection initialized.
if (eventData.type === 'connection_ack') {
this.isWebSocketConnectionInitialized = true;
this.emit(
'isConnectionInitialized',
this.isWebSocketConnectionInitialized,
);
// Return to avoid further code execution.
return;
}
// If Graphql server notifies with a data message for a given subscription.
if (eventData.type === 'data') {
// Check if the payload contain error messages and propagate properly.
if (eventData.payload?.errors) {
const errorsMessage = (
eventData.payload.errors as Array<{ message: string }>
)
.map((item) => item.message)
.join();
this.emit('error', new Error(errorsMessage));
// Return to avoid further code execution.
return;
}
// Otherwise just emit the data with the subscription id.
this.emit(eventData.id, eventData.payload as GraphqlRequestResponse);
// Try to split the 'queryId' by `-`.
// This indicates that user wants the subscription to emit twice.
// Once for the full queryId and once for the part before the `-` separator.
const queryId = eventData.id.split('-');
// If 'queryId' contained a separator `-` symbol...
if (queryId.length > 0)
this.emit(queryId[0], eventData.payload as GraphqlRequestResponse);
}
// If payload type is 'error' propagate it properly.
else if (eventData.type === 'error') {
this.emit('error', new Error(eventData.payload.message as string));
}
}
public stopAllSubscriptions(): void {
this.websocket.stop();
this.activeSubscriptions.clear();
}
/**
* Execute a GraphQL query.
*
* @param {string} queryId The query id or name.
* @param {string} query The query object.
* @param {string} variables An object containing the query variables.
*
* @returns {Promise<RequestResponseWithData>} A promise that resolves with data for the given query if successfull.
* @throws {Error} If the server encounter an error trying to run the query.
* @throws {Error} If the fetch request didn't resolves successfully.
*/
async request(
queryId: string,
query: GraphqlFetchQuery,
variables?: GraphqlQueryVariables,
): Promise<GraphqlRequestResponse> {
try {
// Build the graphql request.
const body: DataRequestMessage['payload'] = {
queryId,
query: jsonToGraphQLQuery(query),
variables: variables ? variables : {},
};
console.log(body);
const response = await fetch(this.endpoint, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
if (!response.ok) {
throw new Error(`HTTP fetch error with status: ${response.status}`);
}
// Validate the response payload.
const result = GraphQLClient.requestResponseSchema.parse(
await response.json(),
);
if ('errors' in result) {
const errorsString = result.errors
.map((error) => error.message)
.join(', ');
throw new Error(
`GraphQL request failed. Detailed errors: ${errorsString}`,
);
}
return result;
} catch (error: unknown) {
throw new Error(`GraphQL request failed. Detailed error: ${error}`);
}
}
/**
* Execute a GraphQL subscription.
*
* NOTE: If you use a queryId that contains a minus(`-`) char in its name (e.g. MonitorForPayments-0xaqwe),
* This will emit twice, once for the full queryId and once for the first part before the `-` separator.
* This is usefull if you want to setup notification handlers that are standard for several subscriptions.
*
* @param {string} queryId The query id or name. Must be unique to avoid conflicts with other active subscriptions.
* @param {string} query The query object.
* @param {string} waitForResponse Indicates if the subscription must await for response data to return.
* @param {string} variables An object containing the query variables.
*
* @returns {Promise<GraphqlRequestResponse>} A promise that resolves with data for the given query if successfull.
* @returns {Promise<void>} Returns void if 'waitForResponse' is false.
* @throws {Error} If the server encounter an error trying to run the subscription.
* @throws {Error} If the websocket connection is unable to resolve the data exchange successfully.
* @throws {Error} If a subscription with the same queryId has been already send.
*/
async subscribe(
queryId: string,
query: GraphqlSubscriptionQuery,
waitForResponse: boolean,
variables?: GraphqlQueryVariables,
): Promise<GraphqlRequestResponse | void> {
// Check if there is an active subscription with the same id.
if (this.activeSubscriptions.has(queryId))
throw new Error(`Subscription with id (${queryId} is already in use.)`);
// A first shakehand with a chaingraph server its necessary before any subscription.
if (!this.isWebSocketConnectionInitialized)
await this.initializeWebsocketConnection();
// Build the graphql subscription.
const subscription: DataRequestMessage = {
id: queryId,
type: 'start',
payload: {
queryId: queryId,
query: jsonToGraphQLQuery(query),
variables: variables ? variables : {},
},
};
await this.websocket.send(JSON.stringify(subscription));
// If 'waitForResponse' was set to 'true' we proceed to await for the server to return data as if were a normal fetch.
// This function will not return until that point.
if (waitForResponse) {
let dataOrError: GraphqlRequestResponse | Error;
// After subscribe await for the server to send us back a message with the id of the subscription we just execute.
await this.waitFor(queryId, (event) => {
// If response is instance of boolean, keep waiting.
if (event instanceof Boolean) return false;
// Since a parsed server message can only be any of these {<GraphQLClientEventsPayload>} 3 types,
// We assert that if not boolean the response is a 'Record' of data or an 'Error' and proceed to finallize the await.
dataOrError = event as GraphqlRequestResponse | Error;
return true;
});
// If server response is instance of error...
if (dataOrError! instanceof Error)
throw new Error(
`Error trying to subscribe to ${queryId}. Detailed info: ${dataOrError.message}`,
);
// Otherwise if server response is a 'Record' of valuable data we proceed to store the query as an active subscription.
this.activeSubscriptions.set(queryId, {
type: 'start',
payload: { query, variables: variables || {} },
});
// Since we are waiting for a server response we grant here that 'dataOrError' variable is not undefined.
return dataOrError!;
}
// Stay waiting asynchronously for a valid server response before add this query to our 'activeSubscriptions' list.
this.waitFor(queryId, (event) => {
if (!(event instanceof Error))
this.activeSubscriptions.set(queryId, {
type: 'start',
payload: { query, variables: variables || {} },
});
return true;
});
}
/**
* Stop a given list of subscriptions from server.
*
* @param {Array<string>} queryIds A list of queryId.
*/
async unsubscribe(queryIds: Array<string>): Promise<void> {
const unsubscribePromises = [];
for (const id of queryIds) {
// Check if the query 'id' is on our list of 'activeSubscriptions'.
// If is not, it means that is not an active subscription and there is nothing to unsubscribe.
if (!this.activeSubscriptions.has(id)) continue;
// Build the graphql unsubscription query.
const unsubscriptionSignal: UnsubscriptionMessage = {
id,
type: 'stop',
};
unsubscribePromises.push(async (): Promise<void> => {
await this.websocket.send(JSON.stringify(unsubscriptionSignal));
this.activeSubscriptions.delete(id);
});
}
// Run all the unsubscriptions in parallel and wait all of them resolve or reject.
await Promise.allSettled(unsubscribePromises);
// If there is no more 'activeSubscriptions' close the WebSocket connection to save bandwith.
if (this.activeSubscriptions.size === 0) this.websocket.stop();
}
private async initializeWebsocketConnection(): Promise<void> {
// Build the graphql connection_init query.
const connectionInitSignal: ConnectionInitMessage = {
type: 'connection_init',
payload: this.token ? { token: this.token } : {},
};
await this.websocket.send(JSON.stringify(connectionInitSignal));
// Wait for the handshake to be accepted by the server.
await this.waitFor(
'isConnectionInitialized',
(isConnectionInitialized) => isConnectionInitialized === true,
);
}
// Validations for our subscription response body.
public static subscriptionResponseSchema = z.union([
z.object({
id: z.string(),
type: z.enum(['connection_error', 'data', 'error']),
payload: z.record(
z.string(),
z.union([
z.record(z.string(), z.unknown()),
z.string(),
z.array(z.unknown()),
]),
),
}),
z.object({
type: z.enum(['connection_ack', 'ka']),
}),
]);
// Validations for our fetch response body.
public static requestResponseSchema = z.union([
z.object({
data: z.record(z.string(), z.unknown()),
}),
z.object({
errors: z.array(z.object({ message: z.string() })),
}),
]);
}
+2
View File
@@ -1,6 +1,7 @@
export * from './event-emitter.js';
export * from './exponential-backoff.js';
export * from './ext-error.js';
export * from './graphql-client.js';
export * from './inverse-bloom-filter.js';
export * from './memoizer.js';
export * from './misc.js';
@@ -8,3 +9,4 @@ export * from './sse-session.js';
export * from './sse-session-msgpackr.js';
export * from './type-utils.js';
export * from './uuid.js';
export * from './ws-client.js';
+2 -2
View File
@@ -3,7 +3,7 @@
export type MemoizerItem<T> = {
value: T;
timestamp: number;
}
};
export class Memoizer {
private cache = new Map<string, MemoizerItem<any>>();
@@ -32,7 +32,7 @@ export class Memoizer {
if (ttl) {
this.removeIfExpired(key, ttl);
}
if (!this.cache.has(key)) {
this.set(key, compute());
}
+250
View File
@@ -0,0 +1,250 @@
import { EventEmitter } from './event-emitter.js';
import { ExponentialBackoff } from './exponential-backoff.js';
/**
* List of events emitted by the WebSocket.
* @event
* @ignore
*/
export type WebSocketEvents = {
/**
* Emitted when data has been received.
* @eventProperty
*/
message: MessageEvent;
/**
* Emitted when socket connects.
* @eventProperty
*/
isOpen: boolean;
/**
* Emitted when the socket has failed in some way.
* @eventProperty
*/
error: Error;
};
/**
* Defines the accepted options by the WebSocket Client.
*/
type Options = {
/**
* Specifies the server port upon the WebSocket Client is going to be connected.
*/
port?: number;
/**
* Specifies the subprotocols(messaging structures) necessary to comunicate with the server side.
*/
protocols?: Array<string> | string;
/**
* Defines the amount of reconnection attempts before throwing failure.
*/
reconnectionAttempts?: number;
};
/**
* A custom Websocket Client implementation.
*/
export class WebsocketClient extends EventEmitter<WebSocketEvents> {
// The websocket connection.
private socket: WebSocket | undefined;
// The exponential backoff handler to re-attempt in case of connection failures.
private exponentialBackoff: ExponentialBackoff;
// Track if service is started or not by the user.
private isServiceStarted: boolean;
// Track if there is an open and working connection to the server.
public isConnected: boolean;
constructor(
public readonly host: string,
public readonly options?: Options,
) {
super();
this.exponentialBackoff = ExponentialBackoff.from({
// NOTE: Here the max delay between attempts is not an arbitray number,
// is an average waiting time that will work for even commonly appalling internet connections.
maxDelay: 15000,
// NOTE: We dont want a lot of forced reconnection attemps by default unless is the desired behavior the user wants,
// 3 attempts by default is an small enough window time frame to not cause any long waiting time.
maxAttempts: this.options?.reconnectionAttempts || 3,
growthRate: 1.3,
});
this.isConnected = false;
this.isServiceStarted = false;
}
static async from(host: string, options?: Options) {
const webSocket = new this(host, options);
webSocket.start();
return webSocket;
}
/**
* Open the WebSocket connection.
*/
public start() {
// If socket is open, not necessary to continue.
if (this.isConnected) return;
// Defines and open the socket connection with the subprotocol if any.
this.socket = new WebSocket(this.getUrl(), this.options?.protocols);
// Subscribe to events emited by the socket.
this.socket.onopen = this.onOpen.bind(this);
this.socket.onmessage = (event: MessageEvent) =>
this.emit('message', event);
this.socket.onclose = this.onClose.bind(this);
this.socket.onerror = this.onError.bind(this);
this.isServiceStarted = true;
// Run the polling function that tracks the 'isConnected' socket status.
this.updateWebsocketClientIsConnectedStatus();
}
public stop(): void {
this.isServiceStarted = false;
// If there is no socket defined is not necessary to continue.
if (!this.socket) return;
this.socket.close();
this.socket = undefined;
}
/**
* Transmits data using the WebSocket connection.
*
* @throws If was not able to connect with the server to send the message.
*/
public async send(
data: string | ArrayBuffer | Blob | ArrayBufferView,
): Promise<void> {
// If socket is not connected try to reconnet it before send the message.
if (!this.isConnected) await this.reconnect();
this.socket!.send(data);
}
private onOpen(): void {
this.isConnected = true;
this.emit('isOpen', true);
}
private async onClose(): Promise<void> {
this.isConnected = false;
this.emit('isOpen', false);
// If connection closes due to some error, but the service is started, try to reconnect.
if (this.isServiceStarted) await this.reconnect();
}
private onError(event: Event): void {
const error = new Error('WebSocket error');
if ('error' in event) {
Object.assign(error, event);
}
this.emit('error', error);
}
/**
* Try to reconnects with the server using an exponential backoff approach.
*/
private async reconnect(): Promise<void> {
await this.exponentialBackoff.run(async () => {
// If socket is 'undefined' or is 'CLOSED' try to start.
if (!this.socket || this.socket.readyState === WebSocket.CLOSED) {
this.start();
throw new Error(
`WebSocket was not initialized, trying to reconnect to ${this.getUrl()}`,
);
}
// If socket is already open just return;
if (this.socket!.readyState === WebSocket.OPEN) return;
if (this.socket!.readyState === WebSocket.CONNECTING) {
try {
// Socket should jump from 'CONNECTING' to 'OPEN' state quite quickly.
// Here we just waits that happens for 5 seconds wich is good timing even for very bad connections.
await this.waitFor('isOpen', (isOpen) => isOpen, 5000);
} catch (error: unknown) {
console.debug(error);
this.socket!.close();
throw new Error(
`Error trying to establish WebSocket connection to ${this.getUrl()}, stuck at 'CONNECTING'`,
);
}
}
if (this.socket!.readyState === WebSocket.CLOSING) {
try {
// Socket should jump from 'CLOSING' to 'CLOSED' state quite quickly.
// Here we just waits that happens for 2 seconds or force socket destruction.
await this.waitFor('isOpen', (isOpen) => !isOpen, 2000);
} catch (error: unknown) {
console.debug(error);
// Detaching socket event emitter functions to avoid memory leaks.
this.socket.onopen = null;
this.socket.onclose = null;
this.socket.onmessage = null;
this.socket.onerror = null;
this.socket = undefined;
throw new Error(
`Error trying to close WebSocket connection from ${this.getUrl()}, stuck at 'CLOSING'`,
);
}
}
});
}
/**
* Update the WebSocket Client 'isConnected' status every 1 second as long as the service has been started.
*/
private async updateWebsocketClientIsConnectedStatus(): Promise<void> {
while (this.isServiceStarted) {
await new Promise((resolve) => {
this.isConnected = this.socket?.readyState === WebSocket.OPEN;
// Resolve the promise after 1 second.
setTimeout(resolve, 1000);
});
}
}
/**
* Util function to transfrom 'https:' urls to 'wss:' urls
*/
private getUrl(): string {
let url = '';
if (!this.host.includes('wss://') && !this.host.includes('ws://'))
// Use encrypted connection by default
url = 'wss://';
url += this.options?.port ? `${this.host}:${this.options.port}` : this.host;
return url;
}
}