From 8d55fa26e06406c31f30308a3ebe47e075e178b5 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Thu, 5 Jun 2025 10:16:52 +0200 Subject: [PATCH 01/16] chore: vip wp fix --- __tests__/WebSocketChannel.test.ts | 287 +++++++++++++++++++++++++++++ src/channel/ws_0_8.ts | 246 ++++++++++++++++++++++--- 2 files changed, 507 insertions(+), 26 deletions(-) diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index 1ed1e45b9..18a54096c 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -191,3 +191,290 @@ describe('websocket regular endpoints - pathfinder test', () => { expect(response).toBe(StarknetChainId.SN_SEPOLIA); }); }); + +describe('WebSocketChannel Buffering', () => { + let webSocketChannel: WebSocketChannel; + const mockNodeUrl = TEST_WS_URL; // Or a specific mock URL if needed + + beforeEach(async () => { + // To test buffering reliably, we need to control WebSocket messages. + // For simplicity in this automated step, we'll assume the actual WebSocket + // connection and message dispatch can be orchestrated or rely on a slight delay. + // A more robust approach would involve mocking the WebSocket object itself. + webSocketChannel = new WebSocketChannel({ nodeUrl: mockNodeUrl }); + await webSocketChannel.waitForConnection(); + }); + + afterEach(async () => { + if (webSocketChannel.isConnected()) { + // Clean up any active subscriptions if tests didn't do so. + // This is tricky without knowing which ones are active. + // For now, we rely on tests to unsubscribe or we just disconnect. + const { subscriptions } = webSocketChannel; + if (subscriptions.get(WSSubscriptions.NEW_HEADS)) { + try { + await webSocketChannel.unsubscribeNewHeads(); + } catch (e) { + /* ignore */ + } + } + if (subscriptions.get(WSSubscriptions.EVENTS)) { + try { + await webSocketChannel.unsubscribeEvents(); + } catch (e) { + /* ignore */ + } + } + // Add other subscription types if necessary + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); + } + }); + + test('should buffer newHeads events and process upon handler attachment', (done) => { + const mockNewHeadsData1 = { + result: { + block_hash: '0x1', + parent_hash: '0x0', + block_number: 1, + new_root: '0x1', + timestamp: 1, + sequencer_address: '0x1', + l1_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, + l1_data_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, + l1_da_mode: 'BLOB', + starknet_version: '0.13.1.1', + }, + subscription: 'sub1', + }; + const mockNewHeadsData2 = { + result: { + block_hash: '0x2', + parent_hash: '0x1', + block_number: 2, + new_root: '0x2', + timestamp: 2, + sequencer_address: '0x2', + l1_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, + l1_data_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, + l1_da_mode: 'BLOB', + starknet_version: '0.13.1.1', + }, + subscription: 'sub1', + }; + + const subIdPromise = webSocketChannel.subscribeNewHeads(); + + subIdPromise + .then((subId) => { + expect(subId).toEqual(expect.any(Number)); + + // Simulate receiving an event BEFORE handler is attached + // This requires a way to manually trigger onmessage or a mock WebSocket + // For this example, we assume a slight delay or manual trigger if possible. + // In a real test, you'd mock WebSocket and call its onmessage. + // Here, we'll proceed assuming the event would be buffered if it arrived. + + // Attach handler + const handler = jest.fn((data) => { + if (handler.mock.calls.length === 1) { + expect(data).toEqual(mockNewHeadsData1); // Assuming this is how data is structured after parsing + } else if (handler.mock.calls.length === 2) { + expect(data).toEqual(mockNewHeadsData2); + webSocketChannel.unsubscribeNewHeads().then(() => done()); + } + }); + + // Manually constructing the MessageEvent-like structure for the proxy + // This is a simplified way to test the internal onMessageProxy logic path. + // Ideally, the WebSocket mock would emit a proper MessageEvent. + const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; + + // Simulate event 1 (buffered) + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockNewHeadsData1, + }), + }); + + webSocketChannel.onNewHeads = handler; // Assign handler, should process buffer + + // Simulate event 2 (processed directly) + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockNewHeadsData2, + }), + }); + }) + .catch(done); + }); + + test('should buffer multiple events and process in order', (done) => { + const mockEventData1 = { + result: { + from_address: '0x1', + keys: [], + data: [], + block_hash: '0xa1', + block_number: 101, + transaction_hash: '0xtx1', + }, + subscription: 'sub2', + }; + const mockEventData2 = { + result: { + from_address: '0x2', + keys: [], + data: [], + block_hash: '0xa2', + block_number: 102, + transaction_hash: '0xtx2', + }, + subscription: 'sub2', + }; + const mockEventData3 = { + result: { + from_address: '0x3', + keys: [], + data: [], + block_hash: '0xa3', + block_number: 103, + transaction_hash: '0xtx3', + }, + subscription: 'sub2', + }; + + webSocketChannel + .subscribeEvents() + .then((subId) => { + expect(subId).toEqual(expect.any(Number)); + + const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; + + // Simulate events arriving before handler attachment + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionEvents', + params: mockEventData1, + }), + }); + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionEvents', + params: mockEventData2, + }), + }); + + const receivedOrder: any[] = []; + const handler = jest.fn((data) => { + receivedOrder.push(data); + if (receivedOrder.length === 3) { + // All 3 events processed + expect(receivedOrder[0]).toEqual(mockEventData1); + expect(receivedOrder[1]).toEqual(mockEventData2); + expect(receivedOrder[2]).toEqual(mockEventData3); + webSocketChannel.unsubscribeEvents().then(() => done()); + } + }); + + webSocketChannel.onEvents = handler; // Assign handler, processes buffer + + // Simulate one more event after handler attachment + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionEvents', + params: mockEventData3, + }), + }); + }) + .catch(done); + }); + + test('handler attached, removed, then re-attached with events in between', (done) => { + const mockData1 = { result: { block_hash: '0xb1' }, subscription: 'subH' }; + const mockData2 = { result: { block_hash: '0xb2' }, subscription: 'subH' }; // Buffered + const mockData3 = { result: { block_hash: '0xb3' }, subscription: 'subH' }; // Buffered + const mockData4 = { result: { block_hash: '0xb4' }, subscription: 'subH' }; // Direct after re-attach + + const handlerA = jest.fn(); + const handlerB = jest.fn(); + const receivedByB: any[] = []; + + const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; + + webSocketChannel + .subscribeNewHeads() + .then(async (subId) => { + expect(subId).toEqual(expect.any(Number)); + + // 1. Attach handler A + webSocketChannel.onNewHeads = handlerA; + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockData1, + }), + }); + expect(handlerA).toHaveBeenCalledWith(mockData1); + expect(handlerA).toHaveBeenCalledTimes(1); + + // 2. Remove handler (set to null or new no-op) + webSocketChannel.onNewHeads = null; + + // 3. Simulate events - these should be buffered + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockData2, + }), + }); + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockData3, + }), + }); + + // 4. Attach handler B + webSocketChannel.onNewHeads = async (data) => { + handlerB(data); + receivedByB.push(data); + + if (receivedByB.length === 3) { + // mockData2, mockData3, mockData4 + expect(handlerB).toHaveBeenCalledTimes(3); + expect(receivedByB[0]).toEqual(mockData2); // Buffered + expect(receivedByB[1]).toEqual(mockData3); // Buffered + expect(receivedByB[2]).toEqual(mockData4); // Direct + await webSocketChannel.unsubscribeNewHeads(); + done(); + } + }; + + // Handler B should have been called with buffered events immediately + expect(handlerB).toHaveBeenCalledWith(mockData2); + expect(handlerB).toHaveBeenCalledWith(mockData3); + expect(handlerB).toHaveBeenCalledTimes(2); // Called for mockData2 and mockData3 from buffer + + // 5. Simulate another event - should go directly to B + internalOnMessageProxy({ + data: JSON.stringify({ + jsonrpc: '2.0', + method: 'starknet_subscriptionNewHeads', + params: mockData4, + }), + }); + }) + .catch(done); + }); +}); diff --git a/src/channel/ws_0_8.ts b/src/channel/ws_0_8.ts index e0483063f..94de4b626 100644 --- a/src/channel/ws_0_8.ts +++ b/src/channel/ws_0_8.ts @@ -75,6 +75,38 @@ export class WebSocketChannel { */ public websocket: WebSocket; + // Private buffers for subscription events + private reorgBuffer: SubscriptionReorgResponse[] = []; + + private newHeadsBuffer: SubscriptionNewHeadsResponse[] = []; + + private eventsBuffer: SubscriptionEventsResponse[] = []; + + private transactionStatusBuffer: SubscriptionTransactionsStatusResponse[] = []; + + private pendingTransactionBuffer: SubscriptionPendingTransactionsResponse[] = []; + + // Private storage for actual event handlers + private onReorgHandler: + | ((this: WebSocketChannel, data: SubscriptionReorgResponse) => any) + | null = null; + + private onNewHeadsHandler: + | ((this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any) + | null = null; + + private onEventsHandler: + | ((this: WebSocketChannel, data: SubscriptionEventsResponse) => any) + | null = null; + + private onTransactionStatusHandler: + | ((this: WebSocketChannel, data: SubscriptionTransactionsStatusResponse) => any) + | null = null; + + private onPendingTransactionHandler: + | ((this: WebSocketChannel, data: SubscriptionPendingTransactionsResponse) => any) + | null = null; + /** * Assign implementation method to get 'on reorg event data' * @example @@ -84,7 +116,19 @@ export class WebSocketChannel { * } * ``` */ - public onReorg: (this: WebSocketChannel, data: SubscriptionReorgResponse) => any = () => {}; + public get onReorg(): (this: WebSocketChannel, data: SubscriptionReorgResponse) => any { + return this.onReorgHandler?.bind(this) || (() => {}); + } + + public set onReorg( + handler: ((this: WebSocketChannel, data: SubscriptionReorgResponse) => any) | null + ) { + this.onReorgHandler = handler ? handler.bind(this) : null; + if (this.onReorgHandler) { + this.reorgBuffer.forEach((data) => this.onReorgHandler!(data)); + this.reorgBuffer = []; + } + } /** * Assign implementation method to get 'starknet block heads' @@ -95,7 +139,19 @@ export class WebSocketChannel { * } * ``` */ - public onNewHeads: (this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any = () => {}; + public get onNewHeads(): (this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any { + return this.onNewHeadsHandler?.bind(this) || (() => {}); + } + + public set onNewHeads( + handler: ((this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any) | null + ) { + this.onNewHeadsHandler = handler ? handler.bind(this) : null; + if (this.onNewHeadsHandler) { + this.newHeadsBuffer.forEach((data) => this.onNewHeadsHandler!(data)); + this.newHeadsBuffer = []; + } + } /** * Assign implementation method to get 'starknet events' @@ -106,7 +162,19 @@ export class WebSocketChannel { * } * ``` */ - public onEvents: (this: WebSocketChannel, data: SubscriptionEventsResponse) => any = () => {}; + public get onEvents(): (this: WebSocketChannel, data: SubscriptionEventsResponse) => any { + return this.onEventsHandler?.bind(this) || (() => {}); + } + + public set onEvents( + handler: ((this: WebSocketChannel, data: SubscriptionEventsResponse) => any) | null + ) { + this.onEventsHandler = handler ? handler.bind(this) : null; + if (this.onEventsHandler) { + this.eventsBuffer.forEach((data) => this.onEventsHandler!(data)); + this.eventsBuffer = []; + } + } /** * Assign method to get 'starknet transactions status' @@ -117,10 +185,22 @@ export class WebSocketChannel { * } * ``` */ - public onTransactionStatus: ( + public get onTransactionStatus(): ( this: WebSocketChannel, data: SubscriptionTransactionsStatusResponse - ) => any = () => {}; + ) => any { + return this.onTransactionStatusHandler?.bind(this) || (() => {}); + } + + public set onTransactionStatus( + handler: ((this: WebSocketChannel, data: SubscriptionTransactionsStatusResponse) => any) | null + ) { + this.onTransactionStatusHandler = handler ? handler.bind(this) : null; + if (this.onTransactionStatusHandler) { + this.transactionStatusBuffer.forEach((data) => this.onTransactionStatusHandler!(data)); + this.transactionStatusBuffer = []; + } + } /** * Assign implementation method to get 'starknet pending transactions (mempool)' @@ -131,10 +211,22 @@ export class WebSocketChannel { * } * ``` */ - public onPendingTransaction: ( + public get onPendingTransaction(): ( this: WebSocketChannel, data: SubscriptionPendingTransactionsResponse - ) => any = () => {}; + ) => any { + return this.onPendingTransactionHandler?.bind(this) || (() => {}); + } + + public set onPendingTransaction( + handler: ((this: WebSocketChannel, data: SubscriptionPendingTransactionsResponse) => any) | null + ) { + this.onPendingTransactionHandler = handler ? handler.bind(this) : null; + if (this.onPendingTransactionHandler) { + this.pendingTransactionBuffer.forEach((data) => this.onPendingTransactionHandler!(data)); + this.pendingTransactionBuffer = []; + } + } /** * Assign implementation to this method to listen open Event @@ -249,19 +341,54 @@ export class WebSocketChannel { const sendId = this.send(method, params); return new Promise((resolve, reject) => { - if (!this.websocket) return; - this.websocket.onmessage = ({ data }) => { - const message: JRPC.ResponseBody = JSON.parse(data); + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not available or not connected.')); + return; // Exit after rejecting + } + + // Declare errorHandler first so it can be referenced by messageHandler for cleanup + let errorHandler: (event: Event) => void; + const messageHandler = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + console.warn('WebSocket received non-string message data:', event.data); + return; // Ignore non-string data + } + const message: JRPC.ResponseBody = JSON.parse(event.data); if (message.id === sendId) { + this.websocket.removeEventListener('message', messageHandler); + this.websocket.removeEventListener('error', errorHandler); + if ('result' in message) { resolve(message.result); } else { - reject(Error(`error on ${method}, ${message.error}`)); + reject( + new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`) + ); } } - // console.log(`data from ${method} response`, data); }; - this.websocket.onerror = reject; + + errorHandler = (event: Event) => { + this.websocket.removeEventListener('message', messageHandler); + this.websocket.removeEventListener('error', errorHandler); // It removes itself here + reject( + new Error( + `WebSocket error during ${method} (id: ${sendId}): ${event.type || 'Unknown error'}` + ) + ); + }; + + this.websocket.addEventListener('message', messageHandler); + this.websocket.addEventListener('error', errorHandler); + + // Optional: Consider adding a timeout for sendReceive operations + // const timeout = 30000; // 30 seconds + // const timeoutId = setTimeout(() => { + // this.websocket.removeEventListener('message', messageHandler); + // this.websocket.removeEventListener('error', errorHandler); + // reject(new Error(`Timeout waiting for response to ${method} (id: ${sendId}) after ${timeout / 1000}s`)); + // }, timeout); + // Be sure to clearTimeout(timeoutId) in both messageHandler (after processing) and errorHandler. }); } @@ -351,17 +478,49 @@ export class WebSocketChannel { * ``` */ public async waitForUnsubscription(forSubscriptionId?: SUBSCRIPTION_ID) { - // unsubscribe + // Wait for unsubscription event return new Promise((resolve, reject) => { - if (!this.websocket) return; - this.onUnsubscribeLocal = (subscriptionId) => { - if (forSubscriptionId === undefined) { - resolve(subscriptionId); - } else if (subscriptionId === forSubscriptionId) { - resolve(subscriptionId); + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not available or not open. Cannot wait for unsubscription.')); + return; + } + + let localOnError: (event: Event) => void; + + const localOnUnsubscribe = (subscriptionId: SUBSCRIPTION_ID) => { + // Check if this specific handler instance is still the one assigned to onUnsubscribeLocal. + // This helps prevent race conditions if waitForUnsubscription is called multiple times in quick succession, + // though direct reassignment of onUnsubscribeLocal is the primary concern there. + if (this.onUnsubscribeLocal === localOnUnsubscribe) { + this.websocket.removeEventListener('error', localOnError); + // No need to reset this.onUnsubscribeLocal here, as a new call to waitForUnsubscription + // or a direct assignment would overwrite it anyway. The main thing is that this promise is resolved. + + if (forSubscriptionId === undefined) { + resolve(subscriptionId); + } else if (subscriptionId === forSubscriptionId) { + resolve(subscriptionId); + } + // If neither of the above, this specific waiter wasn't for this unsubscriptionId, or it was a general wait and got an ID. + // If forSubscriptionId was provided and doesn't match, this specific promise instance should not resolve. + // However, the current logic resolves if forSubscriptionId is undefined OR if it matches. + } + }; + + localOnError = (event: Event) => { + // Ensure this error handler is still relevant to this specific waiter + if (this.onUnsubscribeLocal === localOnUnsubscribe) { + this.websocket.removeEventListener('error', localOnError); + reject( + new Error( + `WebSocket error while waiting for unsubscription of ${forSubscriptionId || 'any subscription'}: ${event.type || 'Unknown error'}` + ) + ); } }; - this.websocket.onerror = reject; + + this.onUnsubscribeLocal = localOnUnsubscribe; // Assign the new unsubscription handler + this.websocket.addEventListener('error', localOnError); // Add specific error listener }); } @@ -398,19 +557,54 @@ export class WebSocketChannel { switch (eventName) { case 'starknet_subscriptionReorg': - this.onReorg(message.params as SubscriptionReorgResponse); + { + const data = message.params as SubscriptionReorgResponse; + if (this.onReorgHandler) { + this.onReorgHandler(data); + } else { + this.reorgBuffer.push(data); + } + } break; case 'starknet_subscriptionNewHeads': - this.onNewHeads(message.params as SubscriptionNewHeadsResponse); + { + const data = message.params as SubscriptionNewHeadsResponse; + if (this.onNewHeadsHandler) { + this.onNewHeadsHandler(data); + } else { + this.newHeadsBuffer.push(data); + } + } break; case 'starknet_subscriptionEvents': - this.onEvents(message.params as SubscriptionEventsResponse); + { + const data = message.params as SubscriptionEventsResponse; + if (this.onEventsHandler) { + this.onEventsHandler(data); + } else { + this.eventsBuffer.push(data); + } + } break; case 'starknet_subscriptionTransactionStatus': - this.onTransactionStatus(message.params as SubscriptionTransactionsStatusResponse); + { + const data = message.params as SubscriptionTransactionsStatusResponse; + if (this.onTransactionStatusHandler) { + this.onTransactionStatusHandler(data); + } else { + this.transactionStatusBuffer.push(data); + } + } break; case 'starknet_subscriptionPendingTransactions': - this.onPendingTransaction(message.params as SubscriptionPendingTransactionsResponse); + { + const data = message.params as SubscriptionPendingTransactionsResponse; + if (this.onPendingTransactionHandler) { + this.onPendingTransactionHandler(data); + } else { + this.pendingTransactionBuffer.push(data); + } + } break; default: break; From c2e62021cfd72fd48d9e380e4889a6f7646a0359 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Thu, 5 Jun 2025 18:31:56 +0200 Subject: [PATCH 02/16] chore: wp2 --- __tests__/WebSocketChannel.test.ts | 14 +++++++------- src/channel/rpc_0_8_1.ts | 3 +++ 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index 18a54096c..d8c36e968 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -48,7 +48,7 @@ describe('websocket specific endpoints - pathfinder test', () => { test('onUnsubscribe with unsubscribeNewHeads', async () => { const mockOnUnsubscribe = jest.fn().mockImplementation((subId: number) => { - expect(subId).toEqual(expect.any(Number)); + expect(subId).toEqual(expect.any(String)); }); webSocketChannel.onUnsubscribe = mockOnUnsubscribe; @@ -130,14 +130,14 @@ describe('websocket specific endpoints - pathfinder test', () => { i += 1; // TODO : Add data format validation expect(data.result).toBeDefined(); - if (i >= 1) { + if (i >= 2) { const status = await webSocketChannel.unsubscribeTransactionStatus(); expect(status).toBe(true); } }; const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); - expect(subid).toEqual(expect.any(Number)); + expect(subid).toEqual(expect.any(String)); const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); expect(subscriptionId).toEqual(expectedId); @@ -163,7 +163,7 @@ describe('websocket specific endpoints - pathfinder test', () => { }; const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); - expect(subid).toEqual(expect.any(Number)); + expect(subid).toEqual(expect.any(String)); const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); expect(subscriptionId).toEqual(expectedId); @@ -267,7 +267,7 @@ describe('WebSocketChannel Buffering', () => { subIdPromise .then((subId) => { - expect(subId).toEqual(expect.any(Number)); + expect(subId).toEqual(expect.any(String)); // Simulate receiving an event BEFORE handler is attached // This requires a way to manually trigger onmessage or a mock WebSocket @@ -351,7 +351,7 @@ describe('WebSocketChannel Buffering', () => { webSocketChannel .subscribeEvents() .then((subId) => { - expect(subId).toEqual(expect.any(Number)); + expect(subId).toEqual(expect.any(String)); const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; @@ -412,7 +412,7 @@ describe('WebSocketChannel Buffering', () => { webSocketChannel .subscribeNewHeads() .then(async (subId) => { - expect(subId).toEqual(expect.any(Number)); + expect(subId).toEqual(expect.any(String)); // 1. Attach handler A webSocketChannel.onNewHeads = handlerA; diff --git a/src/channel/rpc_0_8_1.ts b/src/channel/rpc_0_8_1.ts index ea94ab3b2..71b12c453 100644 --- a/src/channel/rpc_0_8_1.ts +++ b/src/channel/rpc_0_8_1.ts @@ -190,6 +190,9 @@ export class RpcChannel { } const rawResult = await this.fetch(method, params, (this.requestId += 1)); + const responseForTest = rawResult.clone(); // test + const plainTextBody = await responseForTest.text(); // test + console.log('plainTextBody', plainTextBody); const { error, result } = await rawResult.json(); this.errorHandler(method, params, error); return result as RPC.Methods[T]['result']; From 5e2e02b4f760056f67390fe5d4f8792f3d4d329e Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 9 Jun 2025 13:38:50 +0200 Subject: [PATCH 03/16] feat: improve results --- __tests__/WebSocketChannel.test.ts | 71 +++--- src/channel/ws_0_8.ts | 362 ++++++++++++++++++----------- 2 files changed, 262 insertions(+), 171 deletions(-) diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index d8c36e968..29f89027e 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -64,11 +64,13 @@ describe('websocket specific endpoints - pathfinder test', () => { await webSocketChannel.subscribeNewHeads(); let i = 0; - webSocketChannel.onNewHeads = async function (data) { + webSocketChannel.onNewHeads = async function (result, subscriptionId) { expect(this).toBeInstanceOf(WebSocketChannel); i += 1; // TODO : Add data format validation - expect(data.result).toBeDefined(); + expect(result).toBeDefined(); + const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.NEW_HEADS); + expect(subscriptionId).toEqual(currentSubId); if (i === 2) { const status = await webSocketChannel.unsubscribeNewHeads(); expect(status).toBe(true); @@ -84,10 +86,12 @@ describe('websocket specific endpoints - pathfinder test', () => { await webSocketChannel.subscribeEvents(); let i = 0; - webSocketChannel.onEvents = async (data) => { + webSocketChannel.onEvents = async (result, subscriptionId) => { i += 1; // TODO : Add data format validation - expect(data.result).toBeDefined(); + expect(result).toBeDefined(); + const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.EVENTS); + expect(subscriptionId).toEqual(currentSubId); if (i === 5) { const status = await webSocketChannel.unsubscribeEvents(); expect(status).toBe(true); @@ -103,10 +107,12 @@ describe('websocket specific endpoints - pathfinder test', () => { await webSocketChannel.subscribePendingTransaction(true); let i = 0; - webSocketChannel.onPendingTransaction = async (data) => { + webSocketChannel.onPendingTransaction = async (result, subscriptionId) => { i += 1; // TODO : Add data format validation - expect(data.result).toBeDefined(); + expect(result).toBeDefined(); + const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.PENDING_TRANSACTION); + expect(subscriptionId).toEqual(currentSubId); if (i === 5) { const status = await webSocketChannel.unsubscribePendingTransaction(); expect(status).toBe(true); @@ -125,18 +131,21 @@ describe('websocket specific endpoints - pathfinder test', () => { calldata: [account.address, '10', '0'], }); + const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); + let i = 0; - webSocketChannel.onTransactionStatus = async (data) => { + webSocketChannel.onTransactionStatus = async (result, subscriptionId) => { i += 1; // TODO : Add data format validation - expect(data.result).toBeDefined(); + expect(result).toBeDefined(); + const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); + expect(subscriptionId).toEqual(currentSubId); if (i >= 2) { const status = await webSocketChannel.unsubscribeTransactionStatus(); expect(status).toBe(true); } }; - const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); expect(subid).toEqual(expect.any(String)); const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); @@ -152,10 +161,12 @@ describe('websocket specific endpoints - pathfinder test', () => { }); let i = 0; - webSocketChannel.onTransactionStatus = async (data) => { + webSocketChannel.onTransactionStatus = async (result, subscriptionId) => { i += 1; // TODO : Add data format validation - expect(data.result).toBeDefined(); + expect(result).toBeDefined(); + const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); + expect(subscriptionId).toEqual(currentSubId); if (i >= 1) { const status = await webSocketChannel.unsubscribeTransactionStatus(); expect(status).toBe(true); @@ -187,7 +198,7 @@ describe('websocket regular endpoints - pathfinder test', () => { }); test('regular rpc endpoint', async () => { - const response = await webSocketChannel.sendReceiveAny('starknet_chainId'); + const response = await webSocketChannel.sendReceive('starknet_chainId'); expect(response).toBe(StarknetChainId.SN_SEPOLIA); }); }); @@ -276,11 +287,13 @@ describe('WebSocketChannel Buffering', () => { // Here, we'll proceed assuming the event would be buffered if it arrived. // Attach handler - const handler = jest.fn((data) => { + const handler = jest.fn((result, subscriptionId) => { if (handler.mock.calls.length === 1) { - expect(data).toEqual(mockNewHeadsData1); // Assuming this is how data is structured after parsing + expect(result).toEqual(mockNewHeadsData1.result); // Assuming this is how data is structured after parsing + expect(subscriptionId).toEqual(mockNewHeadsData1.subscription); } else if (handler.mock.calls.length === 2) { - expect(data).toEqual(mockNewHeadsData2); + expect(result).toEqual(mockNewHeadsData2.result); + expect(subscriptionId).toEqual(mockNewHeadsData2.subscription); webSocketChannel.unsubscribeNewHeads().then(() => done()); } }); @@ -372,13 +385,13 @@ describe('WebSocketChannel Buffering', () => { }); const receivedOrder: any[] = []; - const handler = jest.fn((data) => { - receivedOrder.push(data); + const handler = jest.fn((result, subscriptionId) => { + receivedOrder.push({ result, subscriptionId }); if (receivedOrder.length === 3) { // All 3 events processed - expect(receivedOrder[0]).toEqual(mockEventData1); - expect(receivedOrder[1]).toEqual(mockEventData2); - expect(receivedOrder[2]).toEqual(mockEventData3); + expect(receivedOrder[0].result).toEqual(mockEventData1.result); + expect(receivedOrder[1].result).toEqual(mockEventData2.result); + expect(receivedOrder[2].result).toEqual(mockEventData3.result); webSocketChannel.unsubscribeEvents().then(() => done()); } }); @@ -423,7 +436,7 @@ describe('WebSocketChannel Buffering', () => { params: mockData1, }), }); - expect(handlerA).toHaveBeenCalledWith(mockData1); + expect(handlerA).toHaveBeenCalledWith(mockData1.result, mockData1.subscription); expect(handlerA).toHaveBeenCalledTimes(1); // 2. Remove handler (set to null or new no-op) @@ -446,24 +459,24 @@ describe('WebSocketChannel Buffering', () => { }); // 4. Attach handler B - webSocketChannel.onNewHeads = async (data) => { - handlerB(data); - receivedByB.push(data); + webSocketChannel.onNewHeads = async (result, subscriptionId) => { + handlerB(result, subscriptionId); + receivedByB.push(result); if (receivedByB.length === 3) { // mockData2, mockData3, mockData4 expect(handlerB).toHaveBeenCalledTimes(3); - expect(receivedByB[0]).toEqual(mockData2); // Buffered - expect(receivedByB[1]).toEqual(mockData3); // Buffered - expect(receivedByB[2]).toEqual(mockData4); // Direct + expect(receivedByB[0]).toEqual(mockData2.result); // Buffered + expect(receivedByB[1]).toEqual(mockData3.result); // Buffered + expect(receivedByB[2]).toEqual(mockData4.result); // Direct await webSocketChannel.unsubscribeNewHeads(); done(); } }; // Handler B should have been called with buffered events immediately - expect(handlerB).toHaveBeenCalledWith(mockData2); - expect(handlerB).toHaveBeenCalledWith(mockData3); + expect(handlerB).toHaveBeenCalledWith(mockData2.result, mockData2.subscription); + expect(handlerB).toHaveBeenCalledWith(mockData3.result, mockData3.subscription); expect(handlerB).toHaveBeenCalledTimes(2); // Called for mockData2 and mockData3 from buffer // 5. Simulate another event - should go directly to B diff --git a/src/channel/ws_0_8.ts b/src/channel/ws_0_8.ts index 94de4b626..a5c052b83 100644 --- a/src/channel/ws_0_8.ts +++ b/src/channel/ws_0_8.ts @@ -1,12 +1,11 @@ import type { + EMITTED_EVENT, SUBSCRIPTION_ID, SubscriptionEventsResponse, SubscriptionNewHeadsResponse, SubscriptionPendingTransactionsResponse, SubscriptionReorgResponse, SubscriptionTransactionsStatusResponse, - WebSocketEvents, - WebSocketMethods, } from '@starknet-io/starknet-types-08'; import { BigNumberish, SubscriptionBlockIdentifier } from '../types'; @@ -17,6 +16,7 @@ import { stringify } from '../utils/json'; import { bigNumberishArrayToHexadecimalStringArray, toHex } from '../utils/num'; import { Block } from '../utils/provider'; import { config } from '../global/config'; +import { logger } from '../global/logger'; export const WSSubscriptions = { NEW_HEADS: 'newHeads', @@ -40,8 +40,15 @@ export type WebSocketOptions = { * @default WebSocket */ websocket?: WebSocket; + /** + * Maximum number of events to store in the buffer if no handler is attached. + * @default 1000 + */ + maxBufferSize?: number; }; +const DEFAULT_MAX_BUFFER_SIZE = 1000; + /** * WebSocket channel provides communication with Starknet node over long-lived socket connection */ @@ -75,58 +82,81 @@ export class WebSocketChannel { */ public websocket: WebSocket; - // Private buffers for subscription events - private reorgBuffer: SubscriptionReorgResponse[] = []; + // Generic buffer for all subscription events + private genericEventBuffer: Array<{ type: string; data: any }> = []; - private newHeadsBuffer: SubscriptionNewHeadsResponse[] = []; + private readonly maxBufferSize: number; - private eventsBuffer: SubscriptionEventsResponse[] = []; + // Generic map for actual event handlers + private eventHandlers: Map any> = new Map(); - private transactionStatusBuffer: SubscriptionTransactionsStatusResponse[] = []; + // Define known event method names for clarity and type-safety where applicable + private static readonly EVENT_METHOD_REORG = 'starknet_subscriptionReorg'; - private pendingTransactionBuffer: SubscriptionPendingTransactionsResponse[] = []; + private static readonly EVENT_METHOD_NEW_HEADS = 'starknet_subscriptionNewHeads'; - // Private storage for actual event handlers - private onReorgHandler: - | ((this: WebSocketChannel, data: SubscriptionReorgResponse) => any) - | null = null; + private static readonly EVENT_METHOD_EVENTS = 'starknet_subscriptionEvents'; - private onNewHeadsHandler: - | ((this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any) - | null = null; + private static readonly EVENT_METHOD_TRANSACTION_STATUS = + 'starknet_subscriptionTransactionStatus'; - private onEventsHandler: - | ((this: WebSocketChannel, data: SubscriptionEventsResponse) => any) - | null = null; + private static readonly EVENT_METHOD_PENDING_TRANSACTION = + 'starknet_subscriptionPendingTransactions'; - private onTransactionStatusHandler: - | ((this: WebSocketChannel, data: SubscriptionTransactionsStatusResponse) => any) - | null = null; - - private onPendingTransactionHandler: - | ((this: WebSocketChannel, data: SubscriptionPendingTransactionsResponse) => any) - | null = null; + private static readonly KNOWN_EVENT_METHODS = [ + WebSocketChannel.EVENT_METHOD_REORG, + WebSocketChannel.EVENT_METHOD_NEW_HEADS, + WebSocketChannel.EVENT_METHOD_EVENTS, + WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS, + WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION, + ]; /** * Assign implementation method to get 'on reorg event data' * @example * ```typescript - * webSocketChannel.onReorg = async function (data) { + * webSocketChannel.onReorg = async function (result, subscriptionId) { * // ... do something when reorg happens * } * ``` */ - public get onReorg(): (this: WebSocketChannel, data: SubscriptionReorgResponse) => any { - return this.onReorgHandler?.bind(this) || (() => {}); + public get onReorg(): ( + this: WebSocketChannel, + result: SubscriptionReorgResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any { + const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_REORG); + return ( + (handler as ( + this: WebSocketChannel, + result: SubscriptionReorgResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) || (() => {}) + ); } public set onReorg( - handler: ((this: WebSocketChannel, data: SubscriptionReorgResponse) => any) | null + userHandler: + | (( + this: WebSocketChannel, + result: SubscriptionReorgResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) + | null ) { - this.onReorgHandler = handler ? handler.bind(this) : null; - if (this.onReorgHandler) { - this.reorgBuffer.forEach((data) => this.onReorgHandler!(data)); - this.reorgBuffer = []; + const eventType = WebSocketChannel.EVENT_METHOD_REORG; + if (userHandler) { + const boundHandler = userHandler.bind(this); + this.eventHandlers.set(eventType, boundHandler); + + const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); + this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); + eventsToProcess.forEach((bufferedEvent) => { + const eventData = bufferedEvent.data as SubscriptionReorgResponse; + boundHandler(eventData.result, eventData.subscription_id); + }); + } else { + this.eventHandlers.delete(eventType); } } @@ -134,22 +164,48 @@ export class WebSocketChannel { * Assign implementation method to get 'starknet block heads' * @example * ```typescript - * webSocketChannel.onNewHeads = async function (data) { + * webSocketChannel.onNewHeads = async function (result, subscriptionId) { * // ... do something with head data * } * ``` */ - public get onNewHeads(): (this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any { - return this.onNewHeadsHandler?.bind(this) || (() => {}); + public get onNewHeads(): ( + this: WebSocketChannel, + result: SubscriptionNewHeadsResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any { + const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_NEW_HEADS); + return ( + (handler as ( + this: WebSocketChannel, + result: SubscriptionNewHeadsResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) || (() => {}) + ); } public set onNewHeads( - handler: ((this: WebSocketChannel, data: SubscriptionNewHeadsResponse) => any) | null + userHandler: + | (( + this: WebSocketChannel, + result: SubscriptionNewHeadsResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) + | null ) { - this.onNewHeadsHandler = handler ? handler.bind(this) : null; - if (this.onNewHeadsHandler) { - this.newHeadsBuffer.forEach((data) => this.onNewHeadsHandler!(data)); - this.newHeadsBuffer = []; + const eventType = WebSocketChannel.EVENT_METHOD_NEW_HEADS; + if (userHandler) { + const boundHandler = userHandler.bind(this); + this.eventHandlers.set(eventType, boundHandler); + + const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); + this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); + eventsToProcess.forEach((bufferedEvent) => { + const eventData = bufferedEvent.data as SubscriptionNewHeadsResponse; + boundHandler(eventData.result, eventData.subscription_id); + }); + } else { + this.eventHandlers.delete(eventType); } } @@ -157,22 +213,44 @@ export class WebSocketChannel { * Assign implementation method to get 'starknet events' * @example * ```typescript - * webSocketChannel.onEvents = async function (data) { + * webSocketChannel.onEvents = async function (result, subscriptionId) { * // ... do something with event data * } * ``` */ - public get onEvents(): (this: WebSocketChannel, data: SubscriptionEventsResponse) => any { - return this.onEventsHandler?.bind(this) || (() => {}); + public get onEvents(): ( + this: WebSocketChannel, + result: EMITTED_EVENT, + subscriptionId: SUBSCRIPTION_ID + ) => any { + const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_EVENTS); + return ( + (handler as ( + this: WebSocketChannel, + result: EMITTED_EVENT, + subscriptionId: SUBSCRIPTION_ID + ) => any) || (() => {}) + ); } public set onEvents( - handler: ((this: WebSocketChannel, data: SubscriptionEventsResponse) => any) | null + userHandler: + | ((this: WebSocketChannel, result: EMITTED_EVENT, subscriptionId: SUBSCRIPTION_ID) => any) + | null ) { - this.onEventsHandler = handler ? handler.bind(this) : null; - if (this.onEventsHandler) { - this.eventsBuffer.forEach((data) => this.onEventsHandler!(data)); - this.eventsBuffer = []; + const eventType = WebSocketChannel.EVENT_METHOD_EVENTS; + if (userHandler) { + const boundHandler = userHandler.bind(this); + this.eventHandlers.set(eventType, boundHandler); + + const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); + this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); + eventsToProcess.forEach((bufferedEvent) => { + const eventData = bufferedEvent.data as SubscriptionEventsResponse; + boundHandler(eventData.result, eventData.subscription_id); + }); + } else { + this.eventHandlers.delete(eventType); } } @@ -180,25 +258,48 @@ export class WebSocketChannel { * Assign method to get 'starknet transactions status' * @example * ```typescript - * webSocketChannel.onTransactionStatus = async function (data) { + * webSocketChannel.onTransactionStatus = async function (result, subscriptionId) { * // ... do something with tx status data * } * ``` */ public get onTransactionStatus(): ( this: WebSocketChannel, - data: SubscriptionTransactionsStatusResponse + result: SubscriptionTransactionsStatusResponse['result'], + subscriptionId: SUBSCRIPTION_ID ) => any { - return this.onTransactionStatusHandler?.bind(this) || (() => {}); + const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS); + return ( + (handler as ( + this: WebSocketChannel, + result: SubscriptionTransactionsStatusResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) || (() => {}) + ); } public set onTransactionStatus( - handler: ((this: WebSocketChannel, data: SubscriptionTransactionsStatusResponse) => any) | null + userHandler: + | (( + this: WebSocketChannel, + result: SubscriptionTransactionsStatusResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) + | null ) { - this.onTransactionStatusHandler = handler ? handler.bind(this) : null; - if (this.onTransactionStatusHandler) { - this.transactionStatusBuffer.forEach((data) => this.onTransactionStatusHandler!(data)); - this.transactionStatusBuffer = []; + const eventType = WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS; + if (userHandler) { + const boundHandler = userHandler.bind(this); + this.eventHandlers.set(eventType, boundHandler); + + const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); + this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); + eventsToProcess.forEach((bufferedEvent) => { + const eventData = bufferedEvent.data as SubscriptionTransactionsStatusResponse; + boundHandler(eventData.result, eventData.subscription_id); + }); + } else { + this.eventHandlers.delete(eventType); } } @@ -206,25 +307,48 @@ export class WebSocketChannel { * Assign implementation method to get 'starknet pending transactions (mempool)' * @example * ```typescript - * webSocketChannel.onPendingTransaction = async function (data) { + * webSocketChannel.onPendingTransaction = async function (result, subscriptionId) { * // ... do something with pending tx data * } * ``` */ public get onPendingTransaction(): ( this: WebSocketChannel, - data: SubscriptionPendingTransactionsResponse + result: SubscriptionPendingTransactionsResponse['result'], + subscriptionId: SUBSCRIPTION_ID ) => any { - return this.onPendingTransactionHandler?.bind(this) || (() => {}); + const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION); + return ( + (handler as ( + this: WebSocketChannel, + result: SubscriptionPendingTransactionsResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) || (() => {}) + ); } public set onPendingTransaction( - handler: ((this: WebSocketChannel, data: SubscriptionPendingTransactionsResponse) => any) | null + userHandler: + | (( + this: WebSocketChannel, + result: SubscriptionPendingTransactionsResponse['result'], + subscriptionId: SUBSCRIPTION_ID + ) => any) + | null ) { - this.onPendingTransactionHandler = handler ? handler.bind(this) : null; - if (this.onPendingTransactionHandler) { - this.pendingTransactionBuffer.forEach((data) => this.onPendingTransactionHandler!(data)); - this.pendingTransactionBuffer = []; + const eventType = WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION; + if (userHandler) { + const boundHandler = userHandler.bind(this); + this.eventHandlers.set(eventType, boundHandler); + + const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); + this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); + eventsToProcess.forEach((bufferedEvent) => { + const eventData = bufferedEvent.data as SubscriptionPendingTransactionsResponse; + boundHandler(eventData.result, eventData.subscription_id); + }); + } else { + this.eventHandlers.delete(eventType); } } @@ -278,6 +402,7 @@ export class WebSocketChannel { const nodeUrl = options.nodeUrl || 'http://localhost:3000 '; // TODO: implement getDefaultNodeUrl default node when defined by providers? this.nodeUrl = options.websocket ? options.websocket.url : nodeUrl; this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); + this.maxBufferSize = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; this.websocket.addEventListener('open', this.onOpen.bind(this)); this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); @@ -317,13 +442,6 @@ export class WebSocketChannel { return usedId; } - /** - * Any Starknet method not just websocket override - */ - public sendReceiveAny(method: any, params?: any) { - return this.sendReceive(method, params); - } - /** * Send request and receive response over ws line * This method abstract ws messages into request/response model @@ -334,10 +452,7 @@ export class WebSocketChannel { * const response = await this.sendReceive('starknet_method', params); * ``` */ - public sendReceive( - method: T, - params?: WebSocketMethods[T]['params'] - ): Promise { + public sendReceive(method: string, params?: object): Promise { const sendId = this.send(method, params); return new Promise((resolve, reject) => { @@ -359,7 +474,7 @@ export class WebSocketChannel { this.websocket.removeEventListener('error', errorHandler); if ('result' in message) { - resolve(message.result); + resolve(message.result as T); } else { reject( new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`) @@ -455,9 +570,9 @@ export class WebSocketChannel { * @param ref internal usage, only for managed subscriptions */ public async unsubscribe(subscriptionId: SUBSCRIPTION_ID, ref?: string) { - const status = (await this.sendReceive('starknet_unsubscribe', { + const status = await this.sendReceive('starknet_unsubscribe', { subscription_id: subscriptionId, - })) as boolean; + }); if (status) { if (ref) { this.subscriptions.delete(ref); @@ -553,62 +668,25 @@ export class WebSocketChannel { private onMessageProxy(event: MessageEvent) { const message: WebSocketEvent = JSON.parse(event.data); - const eventName = message.method as keyof WebSocketEvents; - - switch (eventName) { - case 'starknet_subscriptionReorg': - { - const data = message.params as SubscriptionReorgResponse; - if (this.onReorgHandler) { - this.onReorgHandler(data); - } else { - this.reorgBuffer.push(data); - } - } - break; - case 'starknet_subscriptionNewHeads': - { - const data = message.params as SubscriptionNewHeadsResponse; - if (this.onNewHeadsHandler) { - this.onNewHeadsHandler(data); - } else { - this.newHeadsBuffer.push(data); - } - } - break; - case 'starknet_subscriptionEvents': - { - const data = message.params as SubscriptionEventsResponse; - if (this.onEventsHandler) { - this.onEventsHandler(data); - } else { - this.eventsBuffer.push(data); - } - } - break; - case 'starknet_subscriptionTransactionStatus': - { - const data = message.params as SubscriptionTransactionsStatusResponse; - if (this.onTransactionStatusHandler) { - this.onTransactionStatusHandler(data); - } else { - this.transactionStatusBuffer.push(data); - } - } - break; - case 'starknet_subscriptionPendingTransactions': - { - const data = message.params as SubscriptionPendingTransactionsResponse; - if (this.onPendingTransactionHandler) { - this.onPendingTransactionHandler(data); - } else { - this.pendingTransactionBuffer.push(data); - } - } - break; - default: - break; + const eventName = message.method; // This is a string, like 'starknet_subscriptionNewHeads' + const eventData = message.params as { result: any; subscription_id: SUBSCRIPTION_ID }; // This is the data payload + + const handler = this.eventHandlers.get(eventName); + + if (handler) { + handler(eventData.result, eventData.subscription_id); // Call the stored (bound) handler + } else if (WebSocketChannel.KNOWN_EVENT_METHODS.includes(eventName)) { + // If no handler is currently attached, but it's a known event type, buffer it. + if (this.genericEventBuffer.length >= this.maxBufferSize) { + const droppedEvent = this.genericEventBuffer.shift(); // Remove the oldest + logger.warn( + `WebSocketChannel: Buffer full (max size: ${this.maxBufferSize}). Dropped oldest event of type: ${droppedEvent?.type}` + ); + } + this.genericEventBuffer.push({ type: eventName, data: eventData }); } + + // Call the general onMessage handler if provided by the user, for all messages. this.onMessage(event); } @@ -619,9 +697,9 @@ export class WebSocketChannel { public subscribeNewHeadsUnmanaged(blockIdentifier?: SubscriptionBlockIdentifier) { const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - return this.sendReceive('starknet_subscribeNewHeads', { + return this.sendReceive('starknet_subscribeNewHeads', { ...{ block_id }, - }) as Promise; + }); } /** @@ -653,11 +731,11 @@ export class WebSocketChannel { blockIdentifier?: SubscriptionBlockIdentifier ) { const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - return this.sendReceive('starknet_subscribeEvents', { + return this.sendReceive('starknet_subscribeEvents', { ...{ from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined }, ...{ keys }, ...{ block_id }, - }) as Promise; + }); } /** @@ -694,10 +772,10 @@ export class WebSocketChannel { ) { const transaction_hash = toHex(transactionHash); const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - return this.sendReceive('starknet_subscribeTransactionStatus', { + return this.sendReceive('starknet_subscribeTransactionStatus', { transaction_hash, ...{ block_id }, - }) as Promise; + }); } /** @@ -727,12 +805,12 @@ export class WebSocketChannel { transactionDetails?: boolean, senderAddress?: BigNumberish[] ) { - return this.sendReceive('starknet_subscribePendingTransactions', { + return this.sendReceive('starknet_subscribePendingTransactions', { ...{ transaction_details: transactionDetails }, ...{ sender_address: senderAddress && bigNumberishArrayToHexadecimalStringArray(senderAddress), }, - }) as Promise; + }); } /** From a5f72c42a52248bf342f0083d9087bc3fb15c0bb Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 9 Jun 2025 14:23:35 +0200 Subject: [PATCH 04/16] feat: improve subscription model to Rx --- __tests__/WebSocketChannel.test.ts | 490 ++++------------- src/channel/index.ts | 3 +- src/channel/ws_0_8.ts | 841 ----------------------------- 3 files changed, 108 insertions(+), 1226 deletions(-) delete mode 100644 src/channel/ws_0_8.ts diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index 29f89027e..6fb71d050 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -1,9 +1,8 @@ -import { Provider, WSSubscriptions, WebSocketChannel } from '../src'; +/* eslint-disable no-underscore-dangle */ +import { Provider, Subscription, WebSocketChannel } from '../src'; import { StarknetChainId } from '../src/global/constants'; import { getTestAccount, getTestProvider, STRKtokenAddress, TEST_WS_URL } from './config/fixtures'; -const nodeUrl = 'wss://sepolia-pathfinder-rpc.spaceshard.io/rpc/v0_8'; - describe('websocket specific endpoints - pathfinder test', () => { // account provider const provider = new Provider(getTestProvider()); @@ -12,25 +11,21 @@ describe('websocket specific endpoints - pathfinder test', () => { // websocket let webSocketChannel: WebSocketChannel; - beforeAll(async () => { + beforeEach(async () => { webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); - expect(webSocketChannel.isConnected()).toBe(false); - try { - await webSocketChannel.waitForConnection(); - } catch (error: any) { - console.log(error.message); - } - expect(webSocketChannel.isConnected()).toBe(true); + await webSocketChannel.waitForConnection(); }); - afterAll(async () => { - expect(webSocketChannel.isConnected()).toBe(true); - webSocketChannel.disconnect(); - await expect(webSocketChannel.waitForDisconnection()).resolves.toBe(WebSocket.CLOSED); + afterEach(async () => { + if (webSocketChannel.isConnected()) { + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); + } }); test('Test WS Error and edge cases', async () => { webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); // should fail as disconnected await expect(webSocketChannel.subscribeNewHeads()).rejects.toThrow(); @@ -39,89 +34,60 @@ describe('websocket specific endpoints - pathfinder test', () => { webSocketChannel.reconnect(); await webSocketChannel.waitForConnection(); - // should succeed after reconnection - await expect(webSocketChannel.subscribeNewHeads()).resolves.toEqual(expect.any(Number)); - - // should fail because already subscribed - await expect(webSocketChannel.subscribeNewHeads()).resolves.toBe(false); - }); - - test('onUnsubscribe with unsubscribeNewHeads', async () => { - const mockOnUnsubscribe = jest.fn().mockImplementation((subId: number) => { - expect(subId).toEqual(expect.any(String)); - }); - webSocketChannel.onUnsubscribe = mockOnUnsubscribe; - - await webSocketChannel.subscribeNewHeads(); - await expect(webSocketChannel.unsubscribeNewHeads()).resolves.toBe(true); - await expect(webSocketChannel.unsubscribeNewHeads()).rejects.toThrow(); - - expect(mockOnUnsubscribe).toHaveBeenCalled(); - expect(webSocketChannel.subscriptions.has(WSSubscriptions.NEW_HEADS)).toBeFalsy(); + // should succeed after reconnection, returning a Subscription object + const sub = await webSocketChannel.subscribeNewHeads(); + expect(sub).toBeInstanceOf(Subscription); + await sub.unsubscribe(); }); test('Test subscribeNewHeads', async () => { - await webSocketChannel.subscribeNewHeads(); + const sub = await webSocketChannel.subscribeNewHeads(); + expect(sub).toBeInstanceOf(Subscription); let i = 0; - webSocketChannel.onNewHeads = async function (result, subscriptionId) { - expect(this).toBeInstanceOf(WebSocketChannel); + sub.on(async (result) => { i += 1; - // TODO : Add data format validation expect(result).toBeDefined(); - const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.NEW_HEADS); - expect(subscriptionId).toEqual(currentSubId); if (i === 2) { - const status = await webSocketChannel.unsubscribeNewHeads(); + const status = await sub.unsubscribe(); expect(status).toBe(true); } - }; - const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.NEW_HEADS); - const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); - expect(subscriptionId).toBe(expectedId); - expect(webSocketChannel.subscriptions.get(WSSubscriptions.NEW_HEADS)).toBe(undefined); + }); + + await webSocketChannel.waitForUnsubscription(sub.id); }); test('Test subscribeEvents', async () => { - await webSocketChannel.subscribeEvents(); + const sub = await webSocketChannel.subscribeEvents(); + expect(sub).toBeInstanceOf(Subscription); let i = 0; - webSocketChannel.onEvents = async (result, subscriptionId) => { + sub.on(async (result) => { i += 1; - // TODO : Add data format validation expect(result).toBeDefined(); - const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.EVENTS); - expect(subscriptionId).toEqual(currentSubId); if (i === 5) { - const status = await webSocketChannel.unsubscribeEvents(); + const status = await sub.unsubscribe(); expect(status).toBe(true); } - }; - const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.EVENTS); - const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); - expect(subscriptionId).toBe(expectedId); - expect(webSocketChannel.subscriptions.get(WSSubscriptions.EVENTS)).toBe(undefined); + }); + + await webSocketChannel.waitForUnsubscription(sub.id); }); test('Test subscribePendingTransaction', async () => { - await webSocketChannel.subscribePendingTransaction(true); + const sub = await webSocketChannel.subscribePendingTransaction(true); + expect(sub).toBeInstanceOf(Subscription); let i = 0; - webSocketChannel.onPendingTransaction = async (result, subscriptionId) => { + sub.on(async (result) => { i += 1; - // TODO : Add data format validation expect(result).toBeDefined(); - const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.PENDING_TRANSACTION); - expect(subscriptionId).toEqual(currentSubId); if (i === 5) { - const status = await webSocketChannel.unsubscribePendingTransaction(); + const status = await sub.unsubscribe(); expect(status).toBe(true); } - }; - const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.PENDING_TRANSACTION); - const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); - expect(subscriptionId).toBe(expectedId); - expect(webSocketChannel.subscriptions.get(WSSubscriptions.PENDING_TRANSACTION)).toBe(undefined); + }); + await webSocketChannel.waitForUnsubscription(sub.id); }); test('Test subscribeTransactionStatus', async () => { @@ -131,54 +97,19 @@ describe('websocket specific endpoints - pathfinder test', () => { calldata: [account.address, '10', '0'], }); - const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); + const sub = await webSocketChannel.subscribeTransactionStatus(transaction_hash); + expect(sub).toBeInstanceOf(Subscription); let i = 0; - webSocketChannel.onTransactionStatus = async (result, subscriptionId) => { + sub.on(async (result) => { i += 1; - // TODO : Add data format validation expect(result).toBeDefined(); - const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); - expect(subscriptionId).toEqual(currentSubId); if (i >= 2) { - const status = await webSocketChannel.unsubscribeTransactionStatus(); + const status = await sub.unsubscribe(); expect(status).toBe(true); } - }; - - expect(subid).toEqual(expect.any(String)); - const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); - const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); - expect(subscriptionId).toEqual(expectedId); - expect(webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS)).toBe(undefined); - }); - - test('Test subscribeTransactionStatus and block_id', async () => { - const { transaction_hash } = await account.execute({ - contractAddress: STRKtokenAddress, - entrypoint: 'transfer', - calldata: [account.address, '10', '0'], }); - - let i = 0; - webSocketChannel.onTransactionStatus = async (result, subscriptionId) => { - i += 1; - // TODO : Add data format validation - expect(result).toBeDefined(); - const currentSubId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); - expect(subscriptionId).toEqual(currentSubId); - if (i >= 1) { - const status = await webSocketChannel.unsubscribeTransactionStatus(); - expect(status).toBe(true); - } - }; - - const subid = await webSocketChannel.subscribeTransactionStatus(transaction_hash); - expect(subid).toEqual(expect.any(String)); - const expectedId = webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); - const subscriptionId = await webSocketChannel.waitForUnsubscription(expectedId); - expect(subscriptionId).toEqual(expectedId); - expect(webSocketChannel.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS)).toBe(undefined); + await webSocketChannel.waitForUnsubscription(sub.id); }); }); @@ -186,7 +117,7 @@ describe('websocket regular endpoints - pathfinder test', () => { let webSocketChannel: WebSocketChannel; beforeAll(async () => { - webSocketChannel = new WebSocketChannel({ nodeUrl }); + webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); expect(webSocketChannel.isConnected()).toBe(false); const status = await webSocketChannel.waitForConnection(); expect(status).toBe(WebSocket.OPEN); @@ -203,291 +134,82 @@ describe('websocket regular endpoints - pathfinder test', () => { }); }); -describe('WebSocketChannel Buffering', () => { +describe('WebSocketChannel Buffering with Subscription object', () => { let webSocketChannel: WebSocketChannel; - const mockNodeUrl = TEST_WS_URL; // Or a specific mock URL if needed - - beforeEach(async () => { - // To test buffering reliably, we need to control WebSocket messages. - // For simplicity in this automated step, we'll assume the actual WebSocket - // connection and message dispatch can be orchestrated or rely on a slight delay. - // A more robust approach would involve mocking the WebSocket object itself. - webSocketChannel = new WebSocketChannel({ nodeUrl: mockNodeUrl }); - await webSocketChannel.waitForConnection(); - }); + let sub: Subscription; afterEach(async () => { - if (webSocketChannel.isConnected()) { - // Clean up any active subscriptions if tests didn't do so. - // This is tricky without knowing which ones are active. - // For now, we rely on tests to unsubscribe or we just disconnect. - const { subscriptions } = webSocketChannel; - if (subscriptions.get(WSSubscriptions.NEW_HEADS)) { - try { - await webSocketChannel.unsubscribeNewHeads(); - } catch (e) { - /* ignore */ - } - } - if (subscriptions.get(WSSubscriptions.EVENTS)) { - try { - await webSocketChannel.unsubscribeEvents(); - } catch (e) { - /* ignore */ - } - } - // Add other subscription types if necessary + if (sub && !(sub as any).isUnsubscribed) { + await sub.unsubscribe(); + } + if (webSocketChannel && webSocketChannel.isConnected()) { webSocketChannel.disconnect(); await webSocketChannel.waitForDisconnection(); } }); - test('should buffer newHeads events and process upon handler attachment', (done) => { - const mockNewHeadsData1 = { - result: { - block_hash: '0x1', - parent_hash: '0x0', - block_number: 1, - new_root: '0x1', - timestamp: 1, - sequencer_address: '0x1', - l1_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, - l1_data_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, - l1_da_mode: 'BLOB', - starknet_version: '0.13.1.1', - }, - subscription: 'sub1', - }; - const mockNewHeadsData2 = { - result: { - block_hash: '0x2', - parent_hash: '0x1', - block_number: 2, - new_root: '0x2', - timestamp: 2, - sequencer_address: '0x2', - l1_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, - l1_data_gas_price: { price_in_fri: '0x1', price_in_wei: '0x1' }, - l1_da_mode: 'BLOB', - starknet_version: '0.13.1.1', - }, - subscription: 'sub1', - }; - - const subIdPromise = webSocketChannel.subscribeNewHeads(); - - subIdPromise - .then((subId) => { - expect(subId).toEqual(expect.any(String)); - - // Simulate receiving an event BEFORE handler is attached - // This requires a way to manually trigger onmessage or a mock WebSocket - // For this example, we assume a slight delay or manual trigger if possible. - // In a real test, you'd mock WebSocket and call its onmessage. - // Here, we'll proceed assuming the event would be buffered if it arrived. - - // Attach handler - const handler = jest.fn((result, subscriptionId) => { - if (handler.mock.calls.length === 1) { - expect(result).toEqual(mockNewHeadsData1.result); // Assuming this is how data is structured after parsing - expect(subscriptionId).toEqual(mockNewHeadsData1.subscription); - } else if (handler.mock.calls.length === 2) { - expect(result).toEqual(mockNewHeadsData2.result); - expect(subscriptionId).toEqual(mockNewHeadsData2.subscription); - webSocketChannel.unsubscribeNewHeads().then(() => done()); - } - }); - - // Manually constructing the MessageEvent-like structure for the proxy - // This is a simplified way to test the internal onMessageProxy logic path. - // Ideally, the WebSocket mock would emit a proper MessageEvent. - const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; - - // Simulate event 1 (buffered) - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockNewHeadsData1, - }), - }); - - webSocketChannel.onNewHeads = handler; // Assign handler, should process buffer - - // Simulate event 2 (processed directly) - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockNewHeadsData2, - }), - }); - }) - .catch(done); - }); + test('should buffer events and process upon handler attachment', (done) => { + webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); + webSocketChannel.waitForConnection().then(async () => { + // Create a subscription but don't attach a handler yet + sub = await webSocketChannel.subscribeNewHeads(); + + const mockNewHeadsResult1 = { block_number: 1 }; + const mockNewHeadsResult2 = { block_number: 2 }; + + // Simulate receiving events BEFORE handler is attached + sub._handleEvent(mockNewHeadsResult1); + + const handler = jest.fn((result) => { + if (handler.mock.calls.length === 1) { + expect(result).toEqual(mockNewHeadsResult1); // From buffer + } else if (handler.mock.calls.length === 2) { + expect(result).toEqual(mockNewHeadsResult2); // Direct + sub.unsubscribe().then(() => done()); + } + }); + + // Attach handler, which should process the buffer + sub.on(handler); + + // Handler should have been called once with the buffered event + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(mockNewHeadsResult1); + + // Simulate another event, which should be processed directly + sub._handleEvent(mockNewHeadsResult2); - test('should buffer multiple events and process in order', (done) => { - const mockEventData1 = { - result: { - from_address: '0x1', - keys: [], - data: [], - block_hash: '0xa1', - block_number: 101, - transaction_hash: '0xtx1', - }, - subscription: 'sub2', - }; - const mockEventData2 = { - result: { - from_address: '0x2', - keys: [], - data: [], - block_hash: '0xa2', - block_number: 102, - transaction_hash: '0xtx2', - }, - subscription: 'sub2', - }; - const mockEventData3 = { - result: { - from_address: '0x3', - keys: [], - data: [], - block_hash: '0xa3', - block_number: 103, - transaction_hash: '0xtx3', - }, - subscription: 'sub2', - }; - - webSocketChannel - .subscribeEvents() - .then((subId) => { - expect(subId).toEqual(expect.any(String)); - - const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; - - // Simulate events arriving before handler attachment - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionEvents', - params: mockEventData1, - }), - }); - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionEvents', - params: mockEventData2, - }), - }); - - const receivedOrder: any[] = []; - const handler = jest.fn((result, subscriptionId) => { - receivedOrder.push({ result, subscriptionId }); - if (receivedOrder.length === 3) { - // All 3 events processed - expect(receivedOrder[0].result).toEqual(mockEventData1.result); - expect(receivedOrder[1].result).toEqual(mockEventData2.result); - expect(receivedOrder[2].result).toEqual(mockEventData3.result); - webSocketChannel.unsubscribeEvents().then(() => done()); - } - }); - - webSocketChannel.onEvents = handler; // Assign handler, processes buffer - - // Simulate one more event after handler attachment - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionEvents', - params: mockEventData3, - }), - }); - }) - .catch(done); + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(mockNewHeadsResult2); + }); }); - test('handler attached, removed, then re-attached with events in between', (done) => { - const mockData1 = { result: { block_hash: '0xb1' }, subscription: 'subH' }; - const mockData2 = { result: { block_hash: '0xb2' }, subscription: 'subH' }; // Buffered - const mockData3 = { result: { block_hash: '0xb3' }, subscription: 'subH' }; // Buffered - const mockData4 = { result: { block_hash: '0xb4' }, subscription: 'subH' }; // Direct after re-attach - - const handlerA = jest.fn(); - const handlerB = jest.fn(); - const receivedByB: any[] = []; - - const internalOnMessageProxy = (webSocketChannel as any).onMessageProxy; - - webSocketChannel - .subscribeNewHeads() - .then(async (subId) => { - expect(subId).toEqual(expect.any(String)); - - // 1. Attach handler A - webSocketChannel.onNewHeads = handlerA; - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockData1, - }), - }); - expect(handlerA).toHaveBeenCalledWith(mockData1.result, mockData1.subscription); - expect(handlerA).toHaveBeenCalledTimes(1); - - // 2. Remove handler (set to null or new no-op) - webSocketChannel.onNewHeads = null; - - // 3. Simulate events - these should be buffered - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockData2, - }), - }); - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockData3, - }), - }); - - // 4. Attach handler B - webSocketChannel.onNewHeads = async (result, subscriptionId) => { - handlerB(result, subscriptionId); - receivedByB.push(result); - - if (receivedByB.length === 3) { - // mockData2, mockData3, mockData4 - expect(handlerB).toHaveBeenCalledTimes(3); - expect(receivedByB[0]).toEqual(mockData2.result); // Buffered - expect(receivedByB[1]).toEqual(mockData3.result); // Buffered - expect(receivedByB[2]).toEqual(mockData4.result); // Direct - await webSocketChannel.unsubscribeNewHeads(); - done(); - } - }; - - // Handler B should have been called with buffered events immediately - expect(handlerB).toHaveBeenCalledWith(mockData2.result, mockData2.subscription); - expect(handlerB).toHaveBeenCalledWith(mockData3.result, mockData3.subscription); - expect(handlerB).toHaveBeenCalledTimes(2); // Called for mockData2 and mockData3 from buffer - - // 5. Simulate another event - should go directly to B - internalOnMessageProxy({ - data: JSON.stringify({ - jsonrpc: '2.0', - method: 'starknet_subscriptionNewHeads', - params: mockData4, - }), - }); - }) - .catch(done); + test('should drop oldest events when buffer limit is reached', async () => { + // Set a small buffer size for testing + webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL, maxBufferSize: 2 }); + await webSocketChannel.waitForConnection(); + sub = await webSocketChannel.subscribeNewHeads(); + + const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + + // Simulate 3 events - one more than the buffer size + sub._handleEvent({ block_number: 1 }); + sub._handleEvent({ block_number: 2 }); + sub._handleEvent({ block_number: 3 }); + + // The warning should have been called once, for the third event overflowing the buffer + expect(warnSpy).toHaveBeenCalledTimes(1); + + const handler = jest.fn(); + sub.on(handler); + + // The handler should be called with the two most recent events (2 and 3) + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith({ block_number: 2 }); + expect(handler).toHaveBeenCalledWith({ block_number: 3 }); + // It should NOT have been called with the first, dropped event + expect(handler).not.toHaveBeenCalledWith({ block_number: 1 }); + + warnSpy.mockRestore(); }); }); diff --git a/src/channel/index.ts b/src/channel/index.ts index c3ea655a6..2783e4efd 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -2,4 +2,5 @@ export * as RPC07 from './rpc_0_7_1'; export * as RPC08 from './rpc_0_8_1'; // Default channel export * from './rpc_0_8_1'; -export * from './ws_0_8'; +export { WSSubscriptions, WebSocketChannel } from './ws/ws_0_8'; +export { Subscription } from './ws/subscription'; diff --git a/src/channel/ws_0_8.ts b/src/channel/ws_0_8.ts deleted file mode 100644 index a5c052b83..000000000 --- a/src/channel/ws_0_8.ts +++ /dev/null @@ -1,841 +0,0 @@ -import type { - EMITTED_EVENT, - SUBSCRIPTION_ID, - SubscriptionEventsResponse, - SubscriptionNewHeadsResponse, - SubscriptionPendingTransactionsResponse, - SubscriptionReorgResponse, - SubscriptionTransactionsStatusResponse, -} from '@starknet-io/starknet-types-08'; - -import { BigNumberish, SubscriptionBlockIdentifier } from '../types'; -import { JRPC } from '../types/api'; -import { WebSocketEvent } from '../types/api/jsonrpc'; -import WebSocket from '../utils/connect/ws'; -import { stringify } from '../utils/json'; -import { bigNumberishArrayToHexadecimalStringArray, toHex } from '../utils/num'; -import { Block } from '../utils/provider'; -import { config } from '../global/config'; -import { logger } from '../global/logger'; - -export const WSSubscriptions = { - NEW_HEADS: 'newHeads', - EVENTS: 'events', - TRANSACTION_STATUS: 'transactionStatus', - PENDING_TRANSACTION: 'pendingTransactions', -} as const; - -export type WebSocketOptions = { - /** - * websocket node url address - * @example 'ws://www.host.com/path' - * @default public websocket enabled starknet node - */ - nodeUrl?: string; - /** - * This parameter should be used when working in an environment without native WebSocket support by providing - * an equivalent WebSocket object that conforms to the protocol, e.g. from the 'isows' and/or 'ws' modules - * * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols . - * * https://www.rfc-editor.org/rfc/rfc6455.html#section-1 . - * @default WebSocket - */ - websocket?: WebSocket; - /** - * Maximum number of events to store in the buffer if no handler is attached. - * @default 1000 - */ - maxBufferSize?: number; -}; - -const DEFAULT_MAX_BUFFER_SIZE = 1000; - -/** - * WebSocket channel provides communication with Starknet node over long-lived socket connection - */ -export class WebSocketChannel { - /** - * WebSocket RPC Node URL - * @example 'wss://starknet-node.io/rpc/v0_8' - */ - public nodeUrl: string; - - // public headers: object; - - // readonly retries: number; - - // public requestId: number; - - // readonly blockIdentifier: BlockIdentifier; - - // private chainId?: StarknetChainId; - - // private specVersion?: string; - - // private transactionRetryIntervalFallback?: number; - - // readonly waitMode: Boolean; // behave like web2 rpc and return when tx is processed - - // private batchClient?: BatchClient; - - /** - * ws library object - */ - public websocket: WebSocket; - - // Generic buffer for all subscription events - private genericEventBuffer: Array<{ type: string; data: any }> = []; - - private readonly maxBufferSize: number; - - // Generic map for actual event handlers - private eventHandlers: Map any> = new Map(); - - // Define known event method names for clarity and type-safety where applicable - private static readonly EVENT_METHOD_REORG = 'starknet_subscriptionReorg'; - - private static readonly EVENT_METHOD_NEW_HEADS = 'starknet_subscriptionNewHeads'; - - private static readonly EVENT_METHOD_EVENTS = 'starknet_subscriptionEvents'; - - private static readonly EVENT_METHOD_TRANSACTION_STATUS = - 'starknet_subscriptionTransactionStatus'; - - private static readonly EVENT_METHOD_PENDING_TRANSACTION = - 'starknet_subscriptionPendingTransactions'; - - private static readonly KNOWN_EVENT_METHODS = [ - WebSocketChannel.EVENT_METHOD_REORG, - WebSocketChannel.EVENT_METHOD_NEW_HEADS, - WebSocketChannel.EVENT_METHOD_EVENTS, - WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS, - WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION, - ]; - - /** - * Assign implementation method to get 'on reorg event data' - * @example - * ```typescript - * webSocketChannel.onReorg = async function (result, subscriptionId) { - * // ... do something when reorg happens - * } - * ``` - */ - public get onReorg(): ( - this: WebSocketChannel, - result: SubscriptionReorgResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any { - const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_REORG); - return ( - (handler as ( - this: WebSocketChannel, - result: SubscriptionReorgResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) || (() => {}) - ); - } - - public set onReorg( - userHandler: - | (( - this: WebSocketChannel, - result: SubscriptionReorgResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) - | null - ) { - const eventType = WebSocketChannel.EVENT_METHOD_REORG; - if (userHandler) { - const boundHandler = userHandler.bind(this); - this.eventHandlers.set(eventType, boundHandler); - - const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); - this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); - eventsToProcess.forEach((bufferedEvent) => { - const eventData = bufferedEvent.data as SubscriptionReorgResponse; - boundHandler(eventData.result, eventData.subscription_id); - }); - } else { - this.eventHandlers.delete(eventType); - } - } - - /** - * Assign implementation method to get 'starknet block heads' - * @example - * ```typescript - * webSocketChannel.onNewHeads = async function (result, subscriptionId) { - * // ... do something with head data - * } - * ``` - */ - public get onNewHeads(): ( - this: WebSocketChannel, - result: SubscriptionNewHeadsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any { - const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_NEW_HEADS); - return ( - (handler as ( - this: WebSocketChannel, - result: SubscriptionNewHeadsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) || (() => {}) - ); - } - - public set onNewHeads( - userHandler: - | (( - this: WebSocketChannel, - result: SubscriptionNewHeadsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) - | null - ) { - const eventType = WebSocketChannel.EVENT_METHOD_NEW_HEADS; - if (userHandler) { - const boundHandler = userHandler.bind(this); - this.eventHandlers.set(eventType, boundHandler); - - const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); - this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); - eventsToProcess.forEach((bufferedEvent) => { - const eventData = bufferedEvent.data as SubscriptionNewHeadsResponse; - boundHandler(eventData.result, eventData.subscription_id); - }); - } else { - this.eventHandlers.delete(eventType); - } - } - - /** - * Assign implementation method to get 'starknet events' - * @example - * ```typescript - * webSocketChannel.onEvents = async function (result, subscriptionId) { - * // ... do something with event data - * } - * ``` - */ - public get onEvents(): ( - this: WebSocketChannel, - result: EMITTED_EVENT, - subscriptionId: SUBSCRIPTION_ID - ) => any { - const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_EVENTS); - return ( - (handler as ( - this: WebSocketChannel, - result: EMITTED_EVENT, - subscriptionId: SUBSCRIPTION_ID - ) => any) || (() => {}) - ); - } - - public set onEvents( - userHandler: - | ((this: WebSocketChannel, result: EMITTED_EVENT, subscriptionId: SUBSCRIPTION_ID) => any) - | null - ) { - const eventType = WebSocketChannel.EVENT_METHOD_EVENTS; - if (userHandler) { - const boundHandler = userHandler.bind(this); - this.eventHandlers.set(eventType, boundHandler); - - const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); - this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); - eventsToProcess.forEach((bufferedEvent) => { - const eventData = bufferedEvent.data as SubscriptionEventsResponse; - boundHandler(eventData.result, eventData.subscription_id); - }); - } else { - this.eventHandlers.delete(eventType); - } - } - - /** - * Assign method to get 'starknet transactions status' - * @example - * ```typescript - * webSocketChannel.onTransactionStatus = async function (result, subscriptionId) { - * // ... do something with tx status data - * } - * ``` - */ - public get onTransactionStatus(): ( - this: WebSocketChannel, - result: SubscriptionTransactionsStatusResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any { - const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS); - return ( - (handler as ( - this: WebSocketChannel, - result: SubscriptionTransactionsStatusResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) || (() => {}) - ); - } - - public set onTransactionStatus( - userHandler: - | (( - this: WebSocketChannel, - result: SubscriptionTransactionsStatusResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) - | null - ) { - const eventType = WebSocketChannel.EVENT_METHOD_TRANSACTION_STATUS; - if (userHandler) { - const boundHandler = userHandler.bind(this); - this.eventHandlers.set(eventType, boundHandler); - - const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); - this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); - eventsToProcess.forEach((bufferedEvent) => { - const eventData = bufferedEvent.data as SubscriptionTransactionsStatusResponse; - boundHandler(eventData.result, eventData.subscription_id); - }); - } else { - this.eventHandlers.delete(eventType); - } - } - - /** - * Assign implementation method to get 'starknet pending transactions (mempool)' - * @example - * ```typescript - * webSocketChannel.onPendingTransaction = async function (result, subscriptionId) { - * // ... do something with pending tx data - * } - * ``` - */ - public get onPendingTransaction(): ( - this: WebSocketChannel, - result: SubscriptionPendingTransactionsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any { - const handler = this.eventHandlers.get(WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION); - return ( - (handler as ( - this: WebSocketChannel, - result: SubscriptionPendingTransactionsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) || (() => {}) - ); - } - - public set onPendingTransaction( - userHandler: - | (( - this: WebSocketChannel, - result: SubscriptionPendingTransactionsResponse['result'], - subscriptionId: SUBSCRIPTION_ID - ) => any) - | null - ) { - const eventType = WebSocketChannel.EVENT_METHOD_PENDING_TRANSACTION; - if (userHandler) { - const boundHandler = userHandler.bind(this); - this.eventHandlers.set(eventType, boundHandler); - - const eventsToProcess = this.genericEventBuffer.filter((event) => event.type === eventType); - this.genericEventBuffer = this.genericEventBuffer.filter((event) => event.type !== eventType); - eventsToProcess.forEach((bufferedEvent) => { - const eventData = bufferedEvent.data as SubscriptionPendingTransactionsResponse; - boundHandler(eventData.result, eventData.subscription_id); - }); - } else { - this.eventHandlers.delete(eventType); - } - } - - /** - * Assign implementation to this method to listen open Event - */ - public onOpen: (this: WebSocketChannel, ev: Event) => any = () => {}; - - /** - * Assign implementation to this method to listen close CloseEvent - */ - public onClose: (this: WebSocketChannel, ev: CloseEvent) => any = () => {}; - - /** - * Assign implementation to this method to listen message MessageEvent - */ - public onMessage: (this: WebSocketChannel, ev: MessageEvent) => any = () => {}; - - /** - * Assign implementation to this method to listen error Event - */ - public onError: (this: WebSocketChannel, ev: Event) => any = () => {}; - - /** - * Assign implementation to this method to listen unsubscription - */ - public onUnsubscribe: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = - () => {}; - - private onUnsubscribeLocal: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = - () => {}; - - /** - * JSON RPC latest sent message id - * expecting receiving message to contain same id - */ - private sendId: number = 0; - - /** - * subscriptions ids - * mapped by keys WSSubscriptions - */ - readonly subscriptions: Map = new Map(); - - /** - * Construct class and event listeners - * @param options WebSocketOptions - */ - constructor(options: WebSocketOptions = {}) { - // provided existing websocket - const nodeUrl = options.nodeUrl || 'http://localhost:3000 '; // TODO: implement getDefaultNodeUrl default node when defined by providers? - this.nodeUrl = options.websocket ? options.websocket.url : nodeUrl; - this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); - this.maxBufferSize = options.maxBufferSize ?? DEFAULT_MAX_BUFFER_SIZE; - - this.websocket.addEventListener('open', this.onOpen.bind(this)); - this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); - this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); - this.websocket.addEventListener('error', this.onError.bind(this)); - } - - private idResolver(id?: number) { - // unmanaged user set id - if (id) return id; - // managed id, intentional return old and than increment - // eslint-disable-next-line no-plusplus - return this.sendId++; - } - - /** - * Send data over open ws connection - * * this would only send data on the line without awaiting 'response message' - * @example - * ```typescript - * const sentId = await this.send('starknet_method', params); - * ``` - */ - public send(method: string, params?: object, id?: number) { - if (!this.isConnected()) { - throw Error('WebSocketChannel.send() fail due to socket disconnected'); - } - const usedId = this.idResolver(id); - const rpcRequestBody: JRPC.RequestBody = { - id: usedId, - jsonrpc: '2.0', - method, - ...(params && { params }), - }; - // Stringify should remove undefined params - this.websocket.send(stringify(rpcRequestBody)); - return usedId; - } - - /** - * Send request and receive response over ws line - * This method abstract ws messages into request/response model - * @param method rpc method name - * @param params rpc method parameters - * @example - * ```typescript - * const response = await this.sendReceive('starknet_method', params); - * ``` - */ - public sendReceive(method: string, params?: object): Promise { - const sendId = this.send(method, params); - - return new Promise((resolve, reject) => { - if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { - reject(new Error('WebSocket not available or not connected.')); - return; // Exit after rejecting - } - - // Declare errorHandler first so it can be referenced by messageHandler for cleanup - let errorHandler: (event: Event) => void; - const messageHandler = (event: MessageEvent) => { - if (typeof event.data !== 'string') { - console.warn('WebSocket received non-string message data:', event.data); - return; // Ignore non-string data - } - const message: JRPC.ResponseBody = JSON.parse(event.data); - if (message.id === sendId) { - this.websocket.removeEventListener('message', messageHandler); - this.websocket.removeEventListener('error', errorHandler); - - if ('result' in message) { - resolve(message.result as T); - } else { - reject( - new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`) - ); - } - } - }; - - errorHandler = (event: Event) => { - this.websocket.removeEventListener('message', messageHandler); - this.websocket.removeEventListener('error', errorHandler); // It removes itself here - reject( - new Error( - `WebSocket error during ${method} (id: ${sendId}): ${event.type || 'Unknown error'}` - ) - ); - }; - - this.websocket.addEventListener('message', messageHandler); - this.websocket.addEventListener('error', errorHandler); - - // Optional: Consider adding a timeout for sendReceive operations - // const timeout = 30000; // 30 seconds - // const timeoutId = setTimeout(() => { - // this.websocket.removeEventListener('message', messageHandler); - // this.websocket.removeEventListener('error', errorHandler); - // reject(new Error(`Timeout waiting for response to ${method} (id: ${sendId}) after ${timeout / 1000}s`)); - // }, timeout); - // Be sure to clearTimeout(timeoutId) in both messageHandler (after processing) and errorHandler. - }); - } - - /** - * Helper to check connection is open - */ - public isConnected() { - return this.websocket.readyState === WebSocket.OPEN; - } - - /** - * await while websocket is connected - * * could be used to block the flow until websocket is open - * @example - * ```typescript - * const readyState = await webSocketChannel.waitForConnection(); - * ``` - */ - public async waitForConnection(): Promise { - // Wait websocket to connect - if (this.websocket.readyState !== WebSocket.OPEN) { - return new Promise((resolve, reject) => { - if (!this.websocket) return; - this.websocket.onopen = () => resolve(this.websocket.readyState); - this.websocket.onerror = (error) => { - return reject(error); - }; - }); - } - - return this.websocket.readyState; - } - - /** - * Disconnect the WebSocket connection, optionally using code as the the WebSocket connection close code and reason as the the WebSocket connection close reason. - */ - public disconnect(code?: number, reason?: string) { - this.websocket.close(code, reason); - } - - /** - * await while websocket is disconnected - * @example - * ```typescript - * const readyState = await webSocketChannel.waitForDisconnection(); - * ``` - */ - public async waitForDisconnection(): Promise { - // Wait websocket to disconnect - if (this.websocket.readyState !== WebSocket.CLOSED) { - return new Promise((resolve, reject) => { - if (!this.websocket) return; - this.websocket.onclose = () => resolve(this.websocket.readyState); - this.websocket.onerror = reject; - }); - } - - return this.websocket.readyState; - } - - /** - * Unsubscribe from starknet subscription - * @param subscriptionId - * @param ref internal usage, only for managed subscriptions - */ - public async unsubscribe(subscriptionId: SUBSCRIPTION_ID, ref?: string) { - const status = await this.sendReceive('starknet_unsubscribe', { - subscription_id: subscriptionId, - }); - if (status) { - if (ref) { - this.subscriptions.delete(ref); - } - this.onUnsubscribeLocal(subscriptionId); - this.onUnsubscribe(subscriptionId); - } - return status; - } - - /** - * await while subscription is unsubscribed - * @param forSubscriptionId if defined trigger on subscriptionId else trigger on any - * @returns subscriptionId | onerror(Event) - * @example - * ```typescript - * const subscriptionId = await webSocketChannel.waitForUnsubscription(); - * ``` - */ - public async waitForUnsubscription(forSubscriptionId?: SUBSCRIPTION_ID) { - // Wait for unsubscription event - return new Promise((resolve, reject) => { - if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { - reject(new Error('WebSocket not available or not open. Cannot wait for unsubscription.')); - return; - } - - let localOnError: (event: Event) => void; - - const localOnUnsubscribe = (subscriptionId: SUBSCRIPTION_ID) => { - // Check if this specific handler instance is still the one assigned to onUnsubscribeLocal. - // This helps prevent race conditions if waitForUnsubscription is called multiple times in quick succession, - // though direct reassignment of onUnsubscribeLocal is the primary concern there. - if (this.onUnsubscribeLocal === localOnUnsubscribe) { - this.websocket.removeEventListener('error', localOnError); - // No need to reset this.onUnsubscribeLocal here, as a new call to waitForUnsubscription - // or a direct assignment would overwrite it anyway. The main thing is that this promise is resolved. - - if (forSubscriptionId === undefined) { - resolve(subscriptionId); - } else if (subscriptionId === forSubscriptionId) { - resolve(subscriptionId); - } - // If neither of the above, this specific waiter wasn't for this unsubscriptionId, or it was a general wait and got an ID. - // If forSubscriptionId was provided and doesn't match, this specific promise instance should not resolve. - // However, the current logic resolves if forSubscriptionId is undefined OR if it matches. - } - }; - - localOnError = (event: Event) => { - // Ensure this error handler is still relevant to this specific waiter - if (this.onUnsubscribeLocal === localOnUnsubscribe) { - this.websocket.removeEventListener('error', localOnError); - reject( - new Error( - `WebSocket error while waiting for unsubscription of ${forSubscriptionId || 'any subscription'}: ${event.type || 'Unknown error'}` - ) - ); - } - }; - - this.onUnsubscribeLocal = localOnUnsubscribe; // Assign the new unsubscription handler - this.websocket.addEventListener('error', localOnError); // Add specific error listener - }); - } - - /** - * Reconnect re-create this.websocket instance - */ - public reconnect() { - this.websocket = new WebSocket(this.nodeUrl); - - this.websocket.addEventListener('open', this.onOpen.bind(this)); - this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); - this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); - this.websocket.addEventListener('error', this.onError.bind(this)); - } - - // TODO: Add/Test ping service. It seems this work out of the box from pathfinder. If net disc. it will auto replay. - private reconnectAndUpdate() { - this.reconnect(); - // TODO: attempt n reconnection times - // TODO: replay data from last block received (including it) up to latest - } - - private onCloseProxy(ev: CloseEvent) { - this.websocket.removeEventListener('open', this.onOpen); - this.websocket.removeEventListener('close', this.onCloseProxy); - this.websocket.removeEventListener('message', this.onMessageProxy); - this.websocket.removeEventListener('error', this.onError); - this.onClose(ev); - } - - private onMessageProxy(event: MessageEvent) { - const message: WebSocketEvent = JSON.parse(event.data); - const eventName = message.method; // This is a string, like 'starknet_subscriptionNewHeads' - const eventData = message.params as { result: any; subscription_id: SUBSCRIPTION_ID }; // This is the data payload - - const handler = this.eventHandlers.get(eventName); - - if (handler) { - handler(eventData.result, eventData.subscription_id); // Call the stored (bound) handler - } else if (WebSocketChannel.KNOWN_EVENT_METHODS.includes(eventName)) { - // If no handler is currently attached, but it's a known event type, buffer it. - if (this.genericEventBuffer.length >= this.maxBufferSize) { - const droppedEvent = this.genericEventBuffer.shift(); // Remove the oldest - logger.warn( - `WebSocketChannel: Buffer full (max size: ${this.maxBufferSize}). Dropped oldest event of type: ${droppedEvent?.type}` - ); - } - this.genericEventBuffer.push({ type: eventName, data: eventData }); - } - - // Call the general onMessage handler if provided by the user, for all messages. - this.onMessage(event); - } - - /** - * subscribe to new block heads - * * you can subscribe to this event multiple times and you need to manage subscriptions manually - */ - public subscribeNewHeadsUnmanaged(blockIdentifier?: SubscriptionBlockIdentifier) { - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - - return this.sendReceive('starknet_subscribeNewHeads', { - ...{ block_id }, - }); - } - - /** - * subscribe to new block heads - */ - public async subscribeNewHeads(blockIdentifier?: SubscriptionBlockIdentifier) { - if (this.subscriptions.get(WSSubscriptions.NEW_HEADS)) return false; - const subId = await this.subscribeNewHeadsUnmanaged(blockIdentifier); - this.subscriptions.set(WSSubscriptions.NEW_HEADS, subId); - return subId; - } - - /** - * Unsubscribe newHeads subscription - */ - public async unsubscribeNewHeads() { - const subId = this.subscriptions.get(WSSubscriptions.NEW_HEADS); - if (!subId) throw Error('There is no subscription on this event'); - return this.unsubscribe(subId, WSSubscriptions.NEW_HEADS); - } - - /** - * subscribe to 'starknet events' - * * you can subscribe to this event multiple times and you need to manage subscriptions manually - */ - public subscribeEventsUnmanaged( - fromAddress?: BigNumberish, - keys?: string[][], - blockIdentifier?: SubscriptionBlockIdentifier - ) { - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - return this.sendReceive('starknet_subscribeEvents', { - ...{ from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined }, - ...{ keys }, - ...{ block_id }, - }); - } - - /** - * subscribe to 'starknet events' - */ - public async subscribeEvents( - fromAddress?: BigNumberish, - keys?: string[][], - blockIdentifier?: SubscriptionBlockIdentifier - ) { - if (this.subscriptions.get(WSSubscriptions.EVENTS)) return false; - // eslint-disable-next-line prefer-rest-params - const subId = await this.subscribeEventsUnmanaged(fromAddress, keys, blockIdentifier); - this.subscriptions.set(WSSubscriptions.EVENTS, subId); - return subId; - } - - /** - * Unsubscribe 'starknet events' subscription - */ - public unsubscribeEvents() { - const subId = this.subscriptions.get(WSSubscriptions.EVENTS); - if (!subId) throw Error('There is no subscription ID for this event'); - return this.unsubscribe(subId, WSSubscriptions.EVENTS); - } - - /** - * subscribe to transaction status - * * you can subscribe to this event multiple times and you need to manage subscriptions manually - */ - public subscribeTransactionStatusUnmanaged( - transactionHash: BigNumberish, - blockIdentifier?: SubscriptionBlockIdentifier - ) { - const transaction_hash = toHex(transactionHash); - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - return this.sendReceive('starknet_subscribeTransactionStatus', { - transaction_hash, - ...{ block_id }, - }); - } - - /** - * subscribe to transaction status - */ - public async subscribeTransactionStatus(transactionHash: BigNumberish) { - if (this.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS)) return false; - const subId = await this.subscribeTransactionStatusUnmanaged(transactionHash); - this.subscriptions.set(WSSubscriptions.TRANSACTION_STATUS, subId); - return subId; - } - - /** - * unsubscribe 'transaction status' subscription - */ - public async unsubscribeTransactionStatus() { - const subId = this.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS); - if (!subId) throw Error('There is no subscription ID for this event'); - return this.unsubscribe(subId, WSSubscriptions.TRANSACTION_STATUS); - } - - /** - * subscribe to pending transactions (mempool) - * * you can subscribe to this event multiple times and you need to manage subscriptions manually - */ - public subscribePendingTransactionUnmanaged( - transactionDetails?: boolean, - senderAddress?: BigNumberish[] - ) { - return this.sendReceive('starknet_subscribePendingTransactions', { - ...{ transaction_details: transactionDetails }, - ...{ - sender_address: senderAddress && bigNumberishArrayToHexadecimalStringArray(senderAddress), - }, - }); - } - - /** - * subscribe to pending transactions (mempool) - */ - public async subscribePendingTransaction( - transactionDetails?: boolean, - senderAddress?: BigNumberish[] - ) { - if (this.subscriptions.get(WSSubscriptions.TRANSACTION_STATUS)) return false; - // eslint-disable-next-line no-param-reassign - const subId = await this.subscribePendingTransactionUnmanaged( - transactionDetails, - senderAddress - ); - this.subscriptions.set(WSSubscriptions.PENDING_TRANSACTION, subId); - return subId; - } - - /** - * unsubscribe 'pending transaction' subscription - */ - public async unsubscribePendingTransaction() { - const subId = this.subscriptions.get(WSSubscriptions.PENDING_TRANSACTION); - if (!subId) throw Error('There is no subscription ID for this event'); - return this.unsubscribe(subId, WSSubscriptions.PENDING_TRANSACTION); - } -} From 94eaf7fa089ce089f6cb5d640c8173c272d3fdc2 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 9 Jun 2025 14:24:01 +0200 Subject: [PATCH 05/16] feat: improve subscription model to Rx with New Class --- src/channel/ws/subscription.ts | 82 ++++++ src/channel/ws/ws_0_8.ts | 454 +++++++++++++++++++++++++++++++++ 2 files changed, 536 insertions(+) create mode 100644 src/channel/ws/subscription.ts create mode 100644 src/channel/ws/ws_0_8.ts diff --git a/src/channel/ws/subscription.ts b/src/channel/ws/subscription.ts new file mode 100644 index 000000000..eee307d17 --- /dev/null +++ b/src/channel/ws/subscription.ts @@ -0,0 +1,82 @@ +/* eslint-disable no-underscore-dangle */ +import type { SUBSCRIPTION_ID } from '@starknet-io/starknet-types-08'; +import { logger } from '../../global/logger'; +import type { WebSocketChannel } from './ws_0_8'; + +/** + * Represents a single WebSocket subscription. + * It allows attaching event handlers and unsubscribing. + */ +export class Subscription { + public readonly id: SUBSCRIPTION_ID; + + private readonly channel: WebSocketChannel; + + private listeners: Array<(result: any) => void> = []; + + private buffer: any[] = []; + + private isUnsubscribed = false; + + private readonly maxBufferSize: number; + + constructor(channel: WebSocketChannel, id: SUBSCRIPTION_ID, maxBufferSize: number) { + this.channel = channel; + this.id = id; + this.maxBufferSize = maxBufferSize; + } + + /** + * Internal method to handle an incoming event from the WebSocketChannel. + * It either calls the listeners or buffers the event if no listeners are attached. + * @param result The event data + * @internal + */ + public _handleEvent(result: any) { + if (this.isUnsubscribed) return; + + if (this.listeners.length > 0) { + this.listeners.forEach((listener) => listener(result)); + } else { + if (this.buffer.length >= this.maxBufferSize) { + const droppedEvent = this.buffer.shift(); // Drop the oldest event + logger.warn(`Subscription ${this.id}: Buffer full. Dropping oldest event:`, droppedEvent); + } + this.buffer.push(result); + } + } + + /** + * Attaches a handler to be called for each event from this subscription. + * @param handler A function that will receive the event `result` object. + * @returns The Subscription object, allowing for chaining. + */ + public on(handler: (result: any) => void) { + if (this.isUnsubscribed) { + throw new Error('Subscription has been unsubscribed.'); + } + this.listeners.push(handler); + // When a handler is attached for the first time, process the buffer + if (this.buffer.length > 0) { + this.buffer.forEach((bufferedResult) => handler(bufferedResult)); + this.buffer = []; // Clear buffer + } + return this; // Allow chaining + } + + /** + * Unsubscribes from the node and cleans up local resources. + * @returns A promise that resolves to `true` if the unsubscription was successful. + */ + public async unsubscribe(): Promise { + if (this.isUnsubscribed) return true; + const success = await this.channel.unsubscribe(this.id); + if (success) { + this.isUnsubscribed = true; + this.listeners = []; // Clear listeners + this.buffer = []; // Clear buffer + this.channel.removeSubscription(this.id); // Notify channel to remove it + } + return success; + } +} diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts new file mode 100644 index 000000000..c5d7ce1dc --- /dev/null +++ b/src/channel/ws/ws_0_8.ts @@ -0,0 +1,454 @@ +/* eslint-disable no-underscore-dangle */ +import type { SUBSCRIPTION_ID } from '@starknet-io/starknet-types-08'; + +import { BigNumberish, SubscriptionBlockIdentifier } from '../../types'; +import { JRPC } from '../../types/api'; +import { WebSocketEvent } from '../../types/api/jsonrpc'; +import WebSocket from '../../utils/connect/ws'; +import { stringify } from '../../utils/json'; +import { bigNumberishArrayToHexadecimalStringArray, toHex } from '../../utils/num'; +import { Block } from '../../utils/provider'; +import { config } from '../../global/config'; +import { logger } from '../../global/logger'; +import { Subscription } from './subscription'; + +export const WSSubscriptions = { + NEW_HEADS: 'newHeads', + EVENTS: 'events', + TRANSACTION_STATUS: 'transactionStatus', + PENDING_TRANSACTION: 'pendingTransactions', +} as const; + +export type WebSocketOptions = { + /** + * websocket node url address + * @example 'ws://www.host.com/path' + * @default public websocket enabled starknet node + */ + nodeUrl?: string; + /** + * This parameter should be used when working in an environment without native WebSocket support by providing + * an equivalent WebSocket object that conforms to the protocol, e.g. from the 'isows' and/or 'ws' modules + * * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols . + * * https://www.rfc-editor.org/rfc/rfc6455.html#section-1 . + * @default WebSocket + */ + websocket?: WebSocket; + /** + * The maximum number of events to buffer per subscription when no handler is attached. + * @default 1000 + */ + maxBufferSize?: number; +}; + +/** + * WebSocket channel provides communication with Starknet node over long-lived socket connection + */ +export class WebSocketChannel { + /** + * WebSocket RPC Node URL + * @example 'wss://starknet-node.io/rpc/v0_8' + */ + public nodeUrl: string; + + /** + * ws library object + */ + public websocket: WebSocket; + + // Map of active subscriptions, keyed by their ID. + private activeSubscriptions: Map = new Map(); + + private readonly maxBufferSize: number; + + /** + * Assign implementation to this method to listen open Event + */ + public onOpen: (this: WebSocketChannel, ev: Event) => any = () => {}; + + /** + * Assign implementation to this method to listen close CloseEvent + */ + public onClose: (this: WebSocketChannel, ev: CloseEvent) => any = () => {}; + + /** + * Assign implementation to this method to listen message MessageEvent + */ + public onMessage: (this: WebSocketChannel, ev: MessageEvent) => any = () => {}; + + /** + * Assign implementation to this method to listen error Event + */ + public onError: (this: WebSocketChannel, ev: Event) => any = () => {}; + + /** + * Assign implementation to this method to listen unsubscription + */ + public onUnsubscribe: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = + () => {}; + + private onUnsubscribeLocal: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = + () => {}; + + /** + * JSON RPC latest sent message id + * expecting receiving message to contain same id + */ + private sendId: number = 0; + + /** + * Construct class and event listeners + * @param options WebSocketOptions + */ + constructor(options: WebSocketOptions = {}) { + // provided existing websocket + const nodeUrl = options.nodeUrl || 'http://localhost:3000 '; + this.nodeUrl = options.websocket ? options.websocket.url : nodeUrl; + this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); + this.maxBufferSize = options.maxBufferSize ?? 1000; + + this.websocket.addEventListener('open', this.onOpen.bind(this)); + this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); + this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); + this.websocket.addEventListener('error', this.onError.bind(this)); + } + + private idResolver(id?: number) { + // unmanaged user set id + if (id) return id; + // managed id, intentional return old and than increment + // eslint-disable-next-line no-plusplus + return this.sendId++; + } + + /** + * Send data over open ws connection + * * this would only send data on the line without awaiting 'response message' + * @example + * ```typescript + * const sentId = await this.send('starknet_method', params); + * ``` + */ + public send(method: string, params?: object, id?: number) { + if (!this.isConnected()) { + throw Error('WebSocketChannel.send() fail due to socket disconnected'); + } + const usedId = this.idResolver(id); + const rpcRequestBody: JRPC.RequestBody = { + id: usedId, + jsonrpc: '2.0', + method, + ...(params && { params }), + }; + // Stringify should remove undefined params + this.websocket.send(stringify(rpcRequestBody)); + return usedId; + } + + /** + * Send request and receive response over ws line + * This method abstract ws messages into request/response model + * @param method rpc method name + * @param params rpc method parameters + * @example + * ```typescript + * const response = await this.sendReceive('starknet_method', params); + * ``` + */ + public sendReceive(method: string, params?: object): Promise { + const sendId = this.send(method, params); + + return new Promise((resolve, reject) => { + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not available or not connected.')); + return; // Exit after rejecting + } + + // Declare errorHandler first so it can be referenced by messageHandler for cleanup + let errorHandler: (event: Event) => void; + const messageHandler = (event: MessageEvent) => { + if (typeof event.data !== 'string') { + console.warn('WebSocket received non-string message data:', event.data); + return; // Ignore non-string data + } + const message: JRPC.ResponseBody = JSON.parse(event.data); + if (message.id === sendId) { + this.websocket.removeEventListener('message', messageHandler); + this.websocket.removeEventListener('error', errorHandler); + + if ('result' in message) { + resolve(message.result as T); + } else { + reject( + new Error(`Error on ${method} (id: ${sendId}): ${JSON.stringify(message.error)}`) + ); + } + } + }; + + errorHandler = (event: Event) => { + this.websocket.removeEventListener('message', messageHandler); + this.websocket.removeEventListener('error', errorHandler); // It removes itself here + reject( + new Error( + `WebSocket error during ${method} (id: ${sendId}): ${event.type || 'Unknown error'}` + ) + ); + }; + + this.websocket.addEventListener('message', messageHandler); + this.websocket.addEventListener('error', errorHandler); + }); + } + + /** + * Helper to check connection is open + */ + public isConnected() { + return this.websocket.readyState === WebSocket.OPEN; + } + + /** + * await while websocket is connected + * * could be used to block the flow until websocket is open + * @example + * ```typescript + * const readyState = await webSocketChannel.waitForConnection(); + * ``` + */ + public async waitForConnection(): Promise { + // Wait websocket to connect + if (this.websocket.readyState !== WebSocket.OPEN) { + return new Promise((resolve, reject) => { + if (!this.websocket) return; + this.websocket.onopen = () => resolve(this.websocket.readyState); + this.websocket.onerror = (error) => { + return reject(error); + }; + }); + } + + return this.websocket.readyState; + } + + /** + * Disconnect the WebSocket connection, optionally using code as the the WebSocket connection close code and reason as the the WebSocket connection close reason. + */ + public disconnect(code?: number, reason?: string) { + this.websocket.close(code, reason); + } + + /** + * await while websocket is disconnected + * @example + * ```typescript + * const readyState = await webSocketChannel.waitForDisconnection(); + * ``` + */ + public async waitForDisconnection(): Promise { + // Wait websocket to disconnect + if (this.websocket.readyState !== WebSocket.CLOSED) { + return new Promise((resolve, reject) => { + if (!this.websocket) return; + this.websocket.onclose = () => resolve(this.websocket.readyState); + this.websocket.onerror = reject; + }); + } + + return this.websocket.readyState; + } + + /** + * Unsubscribe from starknet subscription + * @param subscriptionId + */ + public async unsubscribe(subscriptionId: SUBSCRIPTION_ID) { + const status = await this.sendReceive('starknet_unsubscribe', { + subscription_id: subscriptionId, + }); + if (status) { + this.onUnsubscribeLocal(subscriptionId); + this.onUnsubscribe(subscriptionId); + } + return status; + } + + /** + * await while subscription is unsubscribed + * @param forSubscriptionId if defined trigger on subscriptionId else trigger on any + * @returns subscriptionId | onerror(Event) + * @example + * ```typescript + * const subscriptionId = await webSocketChannel.waitForUnsubscription(); + * ``` + */ + public async waitForUnsubscription(forSubscriptionId?: SUBSCRIPTION_ID) { + // Wait for unsubscription event + return new Promise((resolve, reject) => { + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { + reject(new Error('WebSocket not available or not open. Cannot wait for unsubscription.')); + return; + } + + let localOnError: (event: Event) => void; + + const localOnUnsubscribe = (subscriptionId: SUBSCRIPTION_ID) => { + if (this.onUnsubscribeLocal === localOnUnsubscribe) { + this.websocket.removeEventListener('error', localOnError); + + if (forSubscriptionId === undefined) { + resolve(subscriptionId); + } else if (subscriptionId === forSubscriptionId) { + resolve(subscriptionId); + } + } + }; + + localOnError = (event: Event) => { + if (this.onUnsubscribeLocal === localOnUnsubscribe) { + this.websocket.removeEventListener('error', localOnError); + reject( + new Error( + `WebSocket error while waiting for unsubscription of ${forSubscriptionId || 'any subscription'}: ${event.type || 'Unknown error'}` + ) + ); + } + }; + + this.onUnsubscribeLocal = localOnUnsubscribe; + this.websocket.addEventListener('error', localOnError); + }); + } + + /** + * Reconnect re-create this.websocket instance + */ + public reconnect() { + this.websocket = new WebSocket(this.nodeUrl); + + this.websocket.addEventListener('open', this.onOpen.bind(this)); + this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); + this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); + this.websocket.addEventListener('error', this.onError.bind(this)); + } + + private reconnectAndUpdate() { + this.reconnect(); + } + + private onCloseProxy(ev: CloseEvent) { + this.websocket.removeEventListener('open', this.onOpen); + this.websocket.removeEventListener('close', this.onCloseProxy); + this.websocket.removeEventListener('message', this.onMessageProxy); + this.websocket.removeEventListener('error', this.onError); + this.onClose(ev); + } + + private onMessageProxy(event: MessageEvent) { + const message: WebSocketEvent = JSON.parse(event.data); + + // Check if it's a subscription event + if ( + message.method && + 'params' in message && + message.params && + typeof message.params === 'object' && + 'subscription_id' in message.params + ) { + const { result, subscription_id } = message.params as { + result: any; + subscription_id: SUBSCRIPTION_ID; + }; + const subscription = this.activeSubscriptions.get(subscription_id); + + if (subscription) { + subscription._handleEvent(result); + } else { + logger.warn( + `WebSocketChannel: Received event for untracked subscription ID: ${subscription_id}.` + ); + } + } + + // Call the general onMessage handler if provided by the user, for all messages. + this.onMessage(event); + } + + /** + * subscribe to new block heads + */ + public async subscribeNewHeads( + blockIdentifier?: SubscriptionBlockIdentifier + ): Promise { + const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; + const subId = await this.sendReceive('starknet_subscribeNewHeads', { + ...{ block_id }, + }); + const subscription = new Subscription(this, subId, this.maxBufferSize); + this.activeSubscriptions.set(subId, subscription); + return subscription; + } + + /** + * subscribe to 'starknet events' + */ + public async subscribeEvents( + fromAddress?: BigNumberish, + keys?: string[][], + blockIdentifier?: SubscriptionBlockIdentifier + ): Promise { + const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; + const subId = await this.sendReceive('starknet_subscribeEvents', { + ...{ from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined }, + ...{ keys }, + ...{ block_id }, + }); + const subscription = new Subscription(this, subId, this.maxBufferSize); + this.activeSubscriptions.set(subId, subscription); + return subscription; + } + + /** + * subscribe to transaction status + */ + public async subscribeTransactionStatus( + transactionHash: BigNumberish, + blockIdentifier?: SubscriptionBlockIdentifier + ): Promise { + const transaction_hash = toHex(transactionHash); + const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; + const subId = await this.sendReceive('starknet_subscribeTransactionStatus', { + transaction_hash, + ...{ block_id }, + }); + const subscription = new Subscription(this, subId, this.maxBufferSize); + this.activeSubscriptions.set(subId, subscription); + return subscription; + } + + /** + * subscribe to pending transactions (mempool) + */ + public async subscribePendingTransaction( + transactionDetails?: boolean, + senderAddress?: BigNumberish[] + ): Promise { + const subId = await this.sendReceive('starknet_subscribePendingTransactions', { + ...{ transaction_details: transactionDetails }, + ...{ + sender_address: senderAddress && bigNumberishArrayToHexadecimalStringArray(senderAddress), + }, + }); + const subscription = new Subscription(this, subId, this.maxBufferSize); + this.activeSubscriptions.set(subId, subscription); + return subscription; + } + + /** + * internal method to remove subscription from active map + * @internal + */ + public removeSubscription(id: SUBSCRIPTION_ID) { + this.activeSubscriptions.delete(id); + } +} From 4283303646c9c367a2c274ef29f3b19fd54b8ddd Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Tue, 10 Jun 2025 11:13:51 +0200 Subject: [PATCH 06/16] docs: documentation update and clenup --- src/channel/index.ts | 2 +- src/channel/ws/ws_0_8.ts | 11 ------- www/docs/guides/websocket_channel.md | 49 +++++++++++++++++++--------- 3 files changed, 35 insertions(+), 27 deletions(-) diff --git a/src/channel/index.ts b/src/channel/index.ts index 2783e4efd..e82988654 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -2,5 +2,5 @@ export * as RPC07 from './rpc_0_7_1'; export * as RPC08 from './rpc_0_8_1'; // Default channel export * from './rpc_0_8_1'; -export { WSSubscriptions, WebSocketChannel } from './ws/ws_0_8'; +export { WebSocketChannel } from './ws/ws_0_8'; export { Subscription } from './ws/subscription'; diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index c5d7ce1dc..99c683c66 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -12,13 +12,6 @@ import { config } from '../../global/config'; import { logger } from '../../global/logger'; import { Subscription } from './subscription'; -export const WSSubscriptions = { - NEW_HEADS: 'newHeads', - EVENTS: 'events', - TRANSACTION_STATUS: 'transactionStatus', - PENDING_TRANSACTION: 'pendingTransactions', -} as const; - export type WebSocketOptions = { /** * websocket node url address @@ -332,10 +325,6 @@ export class WebSocketChannel { this.websocket.addEventListener('error', this.onError.bind(this)); } - private reconnectAndUpdate() { - this.reconnect(); - } - private onCloseProxy(ev: CloseEvent) { this.websocket.removeEventListener('open', this.onOpen); this.websocket.removeEventListener('close', this.onCloseProxy); diff --git a/www/docs/guides/websocket_channel.md b/www/docs/guides/websocket_channel.md index f448f5584..636d1fc7b 100644 --- a/www/docs/guides/websocket_channel.md +++ b/www/docs/guides/websocket_channel.md @@ -18,7 +18,7 @@ Websocket Channel implements specification methods defined by [@starknet-io/type ### Import ```typescript -import { WebSocketChannel } from 'starknet'; +import { WebSocketChannel, Subscription } from 'starknet'; ``` ### Create instance @@ -27,6 +27,7 @@ import { WebSocketChannel } from 'starknet'; // create new ws channel const webSocketChannel = new WebSocketChannel({ nodeUrl: 'wss://sepolia-pathfinder-rpc.server.io/rpc/v0_8', + maxBufferSize: 200, // Optional: default is 1000 }); // ensure ws channel is open @@ -47,25 +48,43 @@ const webSocketChannel = new WebSocketChannel({ ### Usage +When you call a subscription method like `subscribeNewHeads`, it now returns a `Promise` that resolves with a `Subscription` object. This object is your handle to that specific subscription. + +You can attach a listener to it using the `.on()` method and stop listening with the `.unsubscribe()` method. This new model allows you to have multiple, independent subscriptions to the same type of event. + +Here is a complete example: + ```typescript -// subscribe to event -await webSocketChannel.subscribeNewHeads(); +// 1. Subscribe to an event. This returns a Subscription object. +const subscription = await webSocketChannel.subscribeNewHeads(); + +// 2. Attach a handler to the `.on()` method to process incoming events. +subscription.on((data) => { + console.log('New Head:', data); + // After receiving one event, we can choose to unsubscribe. + unsubscribeFromEvents(); +}); -// define listener method -webSocketChannel.onNewHeads = async function (data) { - //... on event new head data -}; +// 3. To stop receiving events, call the .unsubscribe() method. +async function unsubscribeFromEvents() { + const success = await subscription.unsubscribe(); + console.log('Unsubscribed successfully:', success); +} ``` -Available subscriptions are: +### Buffering -- subscribeNewHeads -- subscribeEvents -- subscribeTransactionStatus -- subscribePendingTransaction +If you subscribe to an event but don't attach a handler with `.on()` immediately, the `Subscription` object will buffer incoming events for you. When you eventually attach a handler, all buffered events will be passed to it in order before any new events are processed. -Complete API can be found on [websocket API section](/docs/next/API/classes/WebSocketChannel) +To prevent memory overflow, the buffer has a maximum size. You can configure this with the `maxBufferSize` option in the `WebSocketChannel` constructor (default is 1000). If the buffer becomes full, the oldest events will be dropped. + +### Available Subscription Methods -### Unmanaged subscriptions +You can subscribe to different types of events using the following methods on the `WebSocketChannel` instance. Each returns a `Promise`. -Websocket channel manage subscription id, but it is limited to one subscription per event type. If you need multiple subscriptions of the same type use \*Unmanaged methods and handle subscriptions manually. +- `subscribeNewHeads` +- `subscribeEvents` +- `subscribeTransactionStatus` +- `subscribePendingTransaction` + +Complete API can be found on [websocket API section](/docs/next/API/classes/WebSocketChannel) From c9eb1685fd252a8cc581dd442143169cd5165902 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Tue, 10 Jun 2025 11:47:42 +0200 Subject: [PATCH 07/16] chore: test clenup --- src/channel/rpc_0_8_1.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/channel/rpc_0_8_1.ts b/src/channel/rpc_0_8_1.ts index 71b12c453..ea94ab3b2 100644 --- a/src/channel/rpc_0_8_1.ts +++ b/src/channel/rpc_0_8_1.ts @@ -190,9 +190,6 @@ export class RpcChannel { } const rawResult = await this.fetch(method, params, (this.requestId += 1)); - const responseForTest = rawResult.clone(); // test - const plainTextBody = await responseForTest.text(); // test - console.log('plainTextBody', plainTextBody); const { error, result } = await rawResult.json(); this.errorHandler(method, params, error); return result as RPC.Methods[T]['result']; From 48693e205858ede71b0674c834f6e7aa26599a9d Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 11 Jun 2025 09:15:16 +0200 Subject: [PATCH 08/16] fix: safe on... events for raw dev overide, expose ws options type --- src/channel/index.ts | 2 +- src/channel/ws/ws_0_8.ts | 134 ++++++++++++++++++++------------------- 2 files changed, 70 insertions(+), 66 deletions(-) diff --git a/src/channel/index.ts b/src/channel/index.ts index e82988654..b426c4af9 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -2,5 +2,5 @@ export * as RPC07 from './rpc_0_7_1'; export * as RPC08 from './rpc_0_8_1'; // Default channel export * from './rpc_0_8_1'; -export { WebSocketChannel } from './ws/ws_0_8'; +export { WebSocketChannel, WebSocketOptions } from './ws/ws_0_8'; export { Subscription } from './ws/subscription'; diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index 99c683c66..ed7fe41e0 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -55,33 +55,64 @@ export class WebSocketChannel { private readonly maxBufferSize: number; /** - * Assign implementation to this method to listen open Event + * An array of listeners to be called when the WebSocket connection is opened. + * This is useful for monitoring the connection's health and state. + * @example + * ```typescript + * webSocketChannel.onOpen.push((ev) => console.log('Connection opened!', ev)); + * ``` */ - public onOpen: (this: WebSocketChannel, ev: Event) => any = () => {}; + public onOpen: Array<(this: WebSocketChannel, ev: Event) => any> = []; /** - * Assign implementation to this method to listen close CloseEvent + * An array of listeners to be called when the WebSocket connection is closed. + * This is useful for monitoring the connection's health and state. + * @example + * ```typescript + * webSocketChannel.onClose.push((ev) => console.log('Connection closed!', ev)); + * ``` */ - public onClose: (this: WebSocketChannel, ev: CloseEvent) => any = () => {}; + public onClose: Array<(this: WebSocketChannel, ev: CloseEvent) => any> = []; /** - * Assign implementation to this method to listen message MessageEvent + * An array of listeners for receiving ALL raw messages from the WebSocket. + * This is an advanced feature and not needed for standard RPC calls or subscriptions. + * Use this as an "escape hatch" for debugging or handling non-standard, custom node events. + * @example + * ```typescript + * webSocketChannel.onMessage.push((ev) => console.log('Raw message received:', ev.data)); + * ``` */ - public onMessage: (this: WebSocketChannel, ev: MessageEvent) => any = () => {}; + public onMessage: Array<(this: WebSocketChannel, ev: MessageEvent) => any> = []; /** - * Assign implementation to this method to listen error Event + * An array of listeners to be called when a WebSocket error occurs. + * This is useful for logging and reacting to connection failures. + * @example + * ```typescript + * webSocketChannel.onError.push((ev) => console.error('A WebSocket error occurred:', ev)); + * ``` */ - public onError: (this: WebSocketChannel, ev: Event) => any = () => {}; + public onError: Array<(this: WebSocketChannel, ev: Event) => any> = []; /** - * Assign implementation to this method to listen unsubscription + * An array of listeners to be called when a subscription is successfully unsubscribed. + * The listener will receive the ID of the unsubscribed subscription. + * @example + * ```typescript + * webSocketChannel.onUnsubscribe.push((id) => console.log(`Unsubscribed from ${id}`)); + * ``` */ - public onUnsubscribe: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = - () => {}; + public onUnsubscribe: Array<(this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any> = + []; - private onUnsubscribeLocal: (this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any = - () => {}; + private openListener = (ev: Event) => this.onOpen.forEach((h) => h.call(this, ev)); + + private closeListener = this.onCloseProxy.bind(this); + + private messageListener = this.onMessageProxy.bind(this); + + private errorListener = (ev: Event) => this.onError.forEach((h) => h.call(this, ev)); /** * JSON RPC latest sent message id @@ -100,10 +131,10 @@ export class WebSocketChannel { this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); this.maxBufferSize = options.maxBufferSize ?? 1000; - this.websocket.addEventListener('open', this.onOpen.bind(this)); - this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); - this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); - this.websocket.addEventListener('error', this.onError.bind(this)); + this.websocket.addEventListener('open', this.openListener); + this.websocket.addEventListener('close', this.closeListener); + this.websocket.addEventListener('message', this.messageListener); + this.websocket.addEventListener('error', this.errorListener); } private idResolver(id?: number) { @@ -260,56 +291,29 @@ export class WebSocketChannel { subscription_id: subscriptionId, }); if (status) { - this.onUnsubscribeLocal(subscriptionId); - this.onUnsubscribe(subscriptionId); + this.onUnsubscribe.forEach((h) => h.call(this, subscriptionId)); } return status; } /** * await while subscription is unsubscribed - * @param forSubscriptionId if defined trigger on subscriptionId else trigger on any - * @returns subscriptionId | onerror(Event) + * @param targetId The ID of the subscription to wait for. * @example * ```typescript - * const subscriptionId = await webSocketChannel.waitForUnsubscription(); + * await webSocketChannel.waitForUnsubscription(subscription.id); * ``` */ - public async waitForUnsubscription(forSubscriptionId?: SUBSCRIPTION_ID) { - // Wait for unsubscription event - return new Promise((resolve, reject) => { - if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { - reject(new Error('WebSocket not available or not open. Cannot wait for unsubscription.')); - return; - } - - let localOnError: (event: Event) => void; - - const localOnUnsubscribe = (subscriptionId: SUBSCRIPTION_ID) => { - if (this.onUnsubscribeLocal === localOnUnsubscribe) { - this.websocket.removeEventListener('error', localOnError); - - if (forSubscriptionId === undefined) { - resolve(subscriptionId); - } else if (subscriptionId === forSubscriptionId) { - resolve(subscriptionId); - } + public waitForUnsubscription(targetId: SUBSCRIPTION_ID): Promise { + return new Promise((resolve) => { + const listener = (unsubId: SUBSCRIPTION_ID) => { + if (unsubId === targetId) { + // remove this specific listener from the array + this.onUnsubscribe = this.onUnsubscribe.filter((l) => l !== listener); + resolve(); } }; - - localOnError = (event: Event) => { - if (this.onUnsubscribeLocal === localOnUnsubscribe) { - this.websocket.removeEventListener('error', localOnError); - reject( - new Error( - `WebSocket error while waiting for unsubscription of ${forSubscriptionId || 'any subscription'}: ${event.type || 'Unknown error'}` - ) - ); - } - }; - - this.onUnsubscribeLocal = localOnUnsubscribe; - this.websocket.addEventListener('error', localOnError); + this.onUnsubscribe.push(listener); }); } @@ -319,18 +323,18 @@ export class WebSocketChannel { public reconnect() { this.websocket = new WebSocket(this.nodeUrl); - this.websocket.addEventListener('open', this.onOpen.bind(this)); - this.websocket.addEventListener('close', this.onCloseProxy.bind(this)); - this.websocket.addEventListener('message', this.onMessageProxy.bind(this)); - this.websocket.addEventListener('error', this.onError.bind(this)); + this.websocket.addEventListener('open', this.openListener); + this.websocket.addEventListener('close', this.closeListener); + this.websocket.addEventListener('message', this.messageListener); + this.websocket.addEventListener('error', this.errorListener); } private onCloseProxy(ev: CloseEvent) { - this.websocket.removeEventListener('open', this.onOpen); - this.websocket.removeEventListener('close', this.onCloseProxy); - this.websocket.removeEventListener('message', this.onMessageProxy); - this.websocket.removeEventListener('error', this.onError); - this.onClose(ev); + this.websocket.removeEventListener('open', this.openListener); + this.websocket.removeEventListener('close', this.closeListener); + this.websocket.removeEventListener('message', this.messageListener); + this.websocket.removeEventListener('error', this.errorListener); + this.onClose.forEach((h) => h.call(this, ev)); } private onMessageProxy(event: MessageEvent) { @@ -360,7 +364,7 @@ export class WebSocketChannel { } // Call the general onMessage handler if provided by the user, for all messages. - this.onMessage(event); + this.onMessage.forEach((h) => h.call(this, event)); } /** From 0db762ea17ddc3c32ad64f485eb7cd391be1fdee Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 11 Jun 2025 16:30:19 +0200 Subject: [PATCH 09/16] fix: ws parse robusts, unsubrscribe cleanup, data types --- src/channel/ws/subscription.ts | 37 ++++++++++++++++++++++------------ src/channel/ws/ws_0_8.ts | 29 +++++++++++++++++++------- 2 files changed, 46 insertions(+), 20 deletions(-) diff --git a/src/channel/ws/subscription.ts b/src/channel/ws/subscription.ts index eee307d17..1e244a669 100644 --- a/src/channel/ws/subscription.ts +++ b/src/channel/ws/subscription.ts @@ -7,14 +7,14 @@ import type { WebSocketChannel } from './ws_0_8'; * Represents a single WebSocket subscription. * It allows attaching event handlers and unsubscribing. */ -export class Subscription { +export class Subscription { public readonly id: SUBSCRIPTION_ID; private readonly channel: WebSocketChannel; - private listeners: Array<(result: any) => void> = []; + private listeners: Array<(result: T) => void> = []; - private buffer: any[] = []; + private buffer: T[] = []; private isUnsubscribed = false; @@ -32,7 +32,7 @@ export class Subscription { * @param result The event data * @internal */ - public _handleEvent(result: any) { + public _handleEvent(result: T) { if (this.isUnsubscribed) return; if (this.listeners.length > 0) { @@ -51,7 +51,7 @@ export class Subscription { * @param handler A function that will receive the event `result` object. * @returns The Subscription object, allowing for chaining. */ - public on(handler: (result: any) => void) { + public on(handler: (result: T) => void) { if (this.isUnsubscribed) { throw new Error('Subscription has been unsubscribed.'); } @@ -69,14 +69,25 @@ export class Subscription { * @returns A promise that resolves to `true` if the unsubscription was successful. */ public async unsubscribe(): Promise { - if (this.isUnsubscribed) return true; - const success = await this.channel.unsubscribe(this.id); - if (success) { - this.isUnsubscribed = true; - this.listeners = []; // Clear listeners - this.buffer = []; // Clear buffer - this.channel.removeSubscription(this.id); // Notify channel to remove it + if (this.isUnsubscribed) { + return true; + } + + // Immediately mark as unsubscribed and clean up local resources + // to prevent memory leaks, regardless of the server's response. + this.isUnsubscribed = true; + this.listeners = []; + this.buffer = []; + this.channel.removeSubscription(this.id); // Notify channel to remove it + + try { + // Attempt to inform the server, but the client-side cleanup is already done. + const success = await this.channel.unsubscribe(this.id); + return success; + } catch (error) { + logger.error(`Error unsubscribing from subscription ${this.id}:`, error); + // Return false as the server-side unsubscription failed. + return false; } - return success; } } diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index ed7fe41e0..299424dcc 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -1,5 +1,12 @@ /* eslint-disable no-underscore-dangle */ -import type { SUBSCRIPTION_ID } from '@starknet-io/starknet-types-08'; +import type { + BLOCK_HEADER, + EMITTED_EVENT, + NEW_TXN_STATUS, + SUBSCRIPTION_ID, + TXN_HASH, + TXN_WITH_HASH, +} from '@starknet-io/starknet-types-08'; import { BigNumberish, SubscriptionBlockIdentifier } from '../../types'; import { JRPC } from '../../types/api'; @@ -50,7 +57,7 @@ export class WebSocketChannel { public websocket: WebSocket; // Map of active subscriptions, keyed by their ID. - private activeSubscriptions: Map = new Map(); + private activeSubscriptions: Map> = new Map(); private readonly maxBufferSize: number; @@ -338,7 +345,15 @@ export class WebSocketChannel { } private onMessageProxy(event: MessageEvent) { - const message: WebSocketEvent = JSON.parse(event.data); + let message: WebSocketEvent; + try { + message = JSON.parse(event.data); + } catch (error) { + logger.error( + `WebSocketChannel: Error parsing incoming message: ${event.data}, Error: ${error}` + ); + return; // Stop processing this malformed message + } // Check if it's a subscription event if ( @@ -372,7 +387,7 @@ export class WebSocketChannel { */ public async subscribeNewHeads( blockIdentifier?: SubscriptionBlockIdentifier - ): Promise { + ): Promise> { const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; const subId = await this.sendReceive('starknet_subscribeNewHeads', { ...{ block_id }, @@ -389,7 +404,7 @@ export class WebSocketChannel { fromAddress?: BigNumberish, keys?: string[][], blockIdentifier?: SubscriptionBlockIdentifier - ): Promise { + ): Promise> { const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; const subId = await this.sendReceive('starknet_subscribeEvents', { ...{ from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined }, @@ -407,7 +422,7 @@ export class WebSocketChannel { public async subscribeTransactionStatus( transactionHash: BigNumberish, blockIdentifier?: SubscriptionBlockIdentifier - ): Promise { + ): Promise> { const transaction_hash = toHex(transactionHash); const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; const subId = await this.sendReceive('starknet_subscribeTransactionStatus', { @@ -425,7 +440,7 @@ export class WebSocketChannel { public async subscribePendingTransaction( transactionDetails?: boolean, senderAddress?: BigNumberish[] - ): Promise { + ): Promise> { const subId = await this.sendReceive('starknet_subscribePendingTransactions', { ...{ transaction_details: transactionDetails }, ...{ From d44f0221c330d3bd3963d67ca7cd5900a4e44141 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 11 Jun 2025 19:04:19 +0200 Subject: [PATCH 10/16] feat: the One Big Beautiful WebScoket --- __tests__/WebSocketChannel.test.ts | 188 ++++++++--- package.json | 2 +- src/channel/index.ts | 1 + src/channel/ws/subscription.ts | 165 +++++++--- src/channel/ws/ws_0_8.ts | 470 +++++++++++++++++++-------- src/provider/index.ts | 2 +- src/utils/errors/index.ts | 30 ++ src/utils/errors/ws.ts | 25 ++ src/utils/eventEmitter.ts | 30 ++ www/docs/guides/websocket_channel.md | 138 +++++--- 10 files changed, 771 insertions(+), 280 deletions(-) create mode 100644 src/utils/errors/ws.ts create mode 100644 src/utils/eventEmitter.ts diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index 6fb71d050..6b8460493 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -23,21 +23,37 @@ describe('websocket specific endpoints - pathfinder test', () => { } }); - test('Test WS Error and edge cases', async () => { + test('should throw an error when sending on a disconnected socket', async () => { + // This test uses its own channel to disable auto-reconnect and isolate the error behavior + const testChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL, autoReconnect: false }); + await testChannel.waitForConnection(); + + testChannel.disconnect(); + await testChannel.waitForDisconnection(); + + // With autoReconnect: false, this should immediately throw, not queue. + await expect(testChannel.subscribeNewHeads()).rejects.toThrow( + 'WebSocketChannel.send() fail due to socket disconnected' + ); + }); + + test('should allow manual reconnection after a user-initiated disconnect', async () => { + // This test uses the default channel from `beforeEach` which has autoReconnect: true webSocketChannel.disconnect(); await webSocketChannel.waitForDisconnection(); - // should fail as disconnected - await expect(webSocketChannel.subscribeNewHeads()).rejects.toThrow(); + // It should not have auto-reconnected because the disconnect was user-initiated + expect(webSocketChannel.isConnected()).toBe(false); - // should reconnect + // Now, manually reconnect webSocketChannel.reconnect(); await webSocketChannel.waitForConnection(); + expect(webSocketChannel.isConnected()).toBe(true); - // should succeed after reconnection, returning a Subscription object - const sub = await webSocketChannel.subscribeNewHeads(); - expect(sub).toBeInstanceOf(Subscription); - await sub.unsubscribe(); + // To prove the connection is working, make a simple RPC call. + // This avoids the flakiness of creating and tearing down a real subscription. + const chainId = await webSocketChannel.sendReceive('starknet_chainId'); + expect(chainId).toBe(StarknetChainId.SN_SEPOLIA); }); test('Test subscribeNewHeads', async () => { @@ -126,6 +142,7 @@ describe('websocket regular endpoints - pathfinder test', () => { afterAll(async () => { expect(webSocketChannel.isConnected()).toBe(true); webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); }); test('regular rpc endpoint', async () => { @@ -139,7 +156,7 @@ describe('WebSocketChannel Buffering with Subscription object', () => { let sub: Subscription; afterEach(async () => { - if (sub && !(sub as any).isUnsubscribed) { + if (sub && !sub.isClosed) { await sub.unsubscribe(); } if (webSocketChannel && webSocketChannel.isConnected()) { @@ -148,68 +165,147 @@ describe('WebSocketChannel Buffering with Subscription object', () => { } }); - test('should buffer events and process upon handler attachment', (done) => { - webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); - webSocketChannel.waitForConnection().then(async () => { - // Create a subscription but don't attach a handler yet - sub = await webSocketChannel.subscribeNewHeads(); + test('should buffer events and process upon handler attachment', async () => { + // This test is for client-side buffering, so we don't need a real connection. + webSocketChannel = new WebSocketChannel({ nodeUrl: 'ws://dummy-url', autoReconnect: false }); + // Mock unsubscribe to prevent network errors during cleanup in afterEach. + jest.spyOn(webSocketChannel, 'unsubscribe').mockResolvedValue(true); - const mockNewHeadsResult1 = { block_number: 1 }; - const mockNewHeadsResult2 = { block_number: 2 }; + // Manually create the subscription, bypassing the network. + const subId = 'mock_sub_id_buffer'; + sub = new Subscription(webSocketChannel, 'starknet_subscribeNewHeads', {}, subId, 1000); + (webSocketChannel as any).activeSubscriptions.set(subId, sub); - // Simulate receiving events BEFORE handler is attached - sub._handleEvent(mockNewHeadsResult1); + const mockNewHeadsResult1 = { block_number: 1 }; + const mockNewHeadsResult2 = { block_number: 2 }; - const handler = jest.fn((result) => { - if (handler.mock.calls.length === 1) { - expect(result).toEqual(mockNewHeadsResult1); // From buffer - } else if (handler.mock.calls.length === 2) { - expect(result).toEqual(mockNewHeadsResult2); // Direct - sub.unsubscribe().then(() => done()); - } - }); + // 1. Simulate receiving an event BEFORE a handler is attached. + sub._handleEvent(mockNewHeadsResult1); - // Attach handler, which should process the buffer - sub.on(handler); + const handler = jest.fn(); - // Handler should have been called once with the buffered event - expect(handler).toHaveBeenCalledTimes(1); - expect(handler).toHaveBeenCalledWith(mockNewHeadsResult1); + // 2. Attach handler, which should immediately process the buffer. + sub.on(handler); - // Simulate another event, which should be processed directly - sub._handleEvent(mockNewHeadsResult2); + expect(handler).toHaveBeenCalledTimes(1); + expect(handler).toHaveBeenCalledWith(mockNewHeadsResult1); - expect(handler).toHaveBeenCalledTimes(2); - expect(handler).toHaveBeenCalledWith(mockNewHeadsResult2); - }); + // 3. Simulate another event, which should be processed directly. + sub._handleEvent(mockNewHeadsResult2); + + expect(handler).toHaveBeenCalledTimes(2); + expect(handler).toHaveBeenCalledWith(mockNewHeadsResult2); }); test('should drop oldest events when buffer limit is reached', async () => { - // Set a small buffer size for testing - webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL, maxBufferSize: 2 }); - await webSocketChannel.waitForConnection(); - sub = await webSocketChannel.subscribeNewHeads(); + // No real connection needed for this test. + webSocketChannel = new WebSocketChannel({ + nodeUrl: 'ws://dummy-url', + maxBufferSize: 2, + autoReconnect: false, + }); + jest.spyOn(webSocketChannel, 'unsubscribe').mockResolvedValue(true); + + // Manually create subscription with a buffer size of 2. + const subId = 'mock_sub_id_drop'; + sub = new Subscription(webSocketChannel, 'starknet_subscribeNewHeads', {}, subId, 2); + (webSocketChannel as any).activeSubscriptions.set(subId, sub); const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); - // Simulate 3 events - one more than the buffer size + // Simulate 3 events to overflow the buffer. sub._handleEvent({ block_number: 1 }); sub._handleEvent({ block_number: 2 }); - sub._handleEvent({ block_number: 3 }); + sub._handleEvent({ block_number: 3 }); // This one should cause the oldest to be dropped. - // The warning should have been called once, for the third event overflowing the buffer expect(warnSpy).toHaveBeenCalledTimes(1); const handler = jest.fn(); sub.on(handler); - // The handler should be called with the two most recent events (2 and 3) + // The handler should be called with the two most recent events. expect(handler).toHaveBeenCalledTimes(2); expect(handler).toHaveBeenCalledWith({ block_number: 2 }); expect(handler).toHaveBeenCalledWith({ block_number: 3 }); - // It should NOT have been called with the first, dropped event - expect(handler).not.toHaveBeenCalledWith({ block_number: 1 }); + expect(handler).not.toHaveBeenCalledWith({ block_number: 1 }); // The first event was dropped. warnSpy.mockRestore(); }); }); + +describe('WebSocketChannel Auto-Reconnection', () => { + let webSocketChannel: WebSocketChannel; + + afterEach(async () => { + // Ensure the channel is always disconnected after each test to prevent open handles. + if (webSocketChannel) { + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); + } + }); + + test('should automatically reconnect on connection drop', (done) => { + // Set a very short reconnection delay for faster tests + webSocketChannel = new WebSocketChannel({ + nodeUrl: TEST_WS_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + // This will be called once on initial connection, and a second time on reconnection. + if (hasReconnected) { + done(); // Test is successful if we get here + } else { + // This is the first connection, now we simulate the drop + hasReconnected = true; + webSocketChannel.websocket.close(); + } + }); + }); + + test('sendReceive should time out if no response is received', async () => { + webSocketChannel = new WebSocketChannel({ + nodeUrl: TEST_WS_URL, + requestTimeout: 100, // Set a short timeout for testing + }); + await webSocketChannel.waitForConnection(); + + // Spy on the 'send' method and prevent it from sending anything. + // This guarantees that we will never get a response and the timeout will be triggered. + const sendSpy = jest.spyOn(webSocketChannel.websocket, 'send').mockImplementation(() => {}); + + // We expect this promise to reject with a timeout error. + await expect( + webSocketChannel.sendReceive('some_method_that_will_never_get_a_response') + ).rejects.toThrow('timed out after 100ms'); + + // Restore the original implementation for other tests + sendSpy.mockRestore(); + }); + + test('should queue sendReceive requests when reconnecting and process them after', (done) => { + webSocketChannel = new WebSocketChannel({ + nodeUrl: TEST_WS_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + if (hasReconnected) { + // 4. Test is done when reconnection is complete + done(); + } else { + // 1. First connection, now simulate a drop + hasReconnected = true; + webSocketChannel.websocket.close(); + + // 2. Immediately try to send a request. It should be queued. + webSocketChannel.sendReceive('starknet_chainId').then((result) => { + // 3. This assertion runs after reconnection and proves the queue was processed. + expect(result).toBe(StarknetChainId.SN_SEPOLIA); + }); + } + }); + }); +}); diff --git a/package.json b/package.json index e93863863..f6a03f07e 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "build:iife": "tsup --clean false --format iife --platform browser", "build:dts": "tsup --clean false --dts-only", "pretest": "npm run lint && npm run ts:check", - "test": "jest -i", + "test": "jest -i --detectOpenHandles", "test:coverage": "jest -i --coverage", "posttest": "npm run format -- --log-level warn", "test:watch": "jest --watch", diff --git a/src/channel/index.ts b/src/channel/index.ts index b426c4af9..9033e0cde 100644 --- a/src/channel/index.ts +++ b/src/channel/index.ts @@ -4,3 +4,4 @@ export * as RPC08 from './rpc_0_8_1'; export * from './rpc_0_8_1'; export { WebSocketChannel, WebSocketOptions } from './ws/ws_0_8'; export { Subscription } from './ws/subscription'; +export { TimeoutError, WebSocketNotConnectedError } from '../utils/errors'; diff --git a/src/channel/ws/subscription.ts b/src/channel/ws/subscription.ts index 1e244a669..17be15102 100644 --- a/src/channel/ws/subscription.ts +++ b/src/channel/ws/subscription.ts @@ -2,92 +2,159 @@ import type { SUBSCRIPTION_ID } from '@starknet-io/starknet-types-08'; import { logger } from '../../global/logger'; import type { WebSocketChannel } from './ws_0_8'; +import { EventEmitter } from '../../utils/eventEmitter'; + +type SubscriptionEvents = { + event: T; + error: Error; + unsubscribe: void; +}; /** - * Represents a single WebSocket subscription. - * It allows attaching event handlers and unsubscribing. + * Represents an active WebSocket subscription. + * + * This class should not be instantiated directly. It is returned by the + * `subscribe` methods on the `WebSocketChannel`. + * + * @template T - The type of data expected from the subscription event. + * @example + * ```typescript + * const channel = new WebSocketChannel({ nodeUrl: 'YOUR_NODE_URL' }); + * await channel.waitForConnection(); + * + * // The 'sub' object is an instance of the Subscription class. + * const sub = await channel.subscribeNewHeads(); + * + * sub.on((data) => { + * console.log('Received new head:', data); + * }); + * + * // ... later + * await sub.unsubscribe(); + * ``` */ export class Subscription { - public readonly id: SUBSCRIPTION_ID; + /** + * The containing `WebSocketChannel` instance. + * @internal + */ + public channel: WebSocketChannel; - private readonly channel: WebSocketChannel; + /** + * The JSON-RPC method used to create this subscription. + * @internal + */ + public method: string; + + /** + * The parameters used to create this subscription. + * @internal + */ + public params: any; + + /** + * The unique identifier for this subscription. + */ + public id: SUBSCRIPTION_ID; - private listeners: Array<(result: T) => void> = []; + private events = new EventEmitter>(); private buffer: T[] = []; - private isUnsubscribed = false; + private maxBufferSize: number; + + private handler: ((data: T) => void) | null = null; - private readonly maxBufferSize: number; + private _isClosed = false; - constructor(channel: WebSocketChannel, id: SUBSCRIPTION_ID, maxBufferSize: number) { + /** + * @internal + * @param {WebSocketChannel} channel - The WebSocketChannel instance. + * @param {string} method - The RPC method used for the subscription. + * @param {any} params - The parameters for the subscription. + * @param {SUBSCRIPTION_ID} id - The subscription ID. + * @param {number} maxBufferSize - The maximum number of events to buffer. + */ + constructor( + channel: WebSocketChannel, + method: string, + params: object, + id: SUBSCRIPTION_ID, + maxBufferSize: number + ) { this.channel = channel; + this.method = method; + this.params = params; this.id = id; this.maxBufferSize = maxBufferSize; } /** - * Internal method to handle an incoming event from the WebSocketChannel. - * It either calls the listeners or buffers the event if no listeners are attached. - * @param result The event data - * @internal + * Indicates if the subscription has been closed. + * @returns {boolean} `true` if unsubscribed, `false` otherwise. */ - public _handleEvent(result: T) { - if (this.isUnsubscribed) return; + public get isClosed(): boolean { + return this._isClosed; + } - if (this.listeners.length > 0) { - this.listeners.forEach((listener) => listener(result)); + /** + * Internal method to handle incoming events from the WebSocket channel. + * If a handler is attached, it's invoked immediately. Otherwise, the event is buffered. + * @internal + * @param {T} data - The event data. + */ + public _handleEvent(data: T): void { + if (this.handler) { + this.handler(data); } else { if (this.buffer.length >= this.maxBufferSize) { const droppedEvent = this.buffer.shift(); // Drop the oldest event logger.warn(`Subscription ${this.id}: Buffer full. Dropping oldest event:`, droppedEvent); } - this.buffer.push(result); + this.buffer.push(data); } } /** - * Attaches a handler to be called for each event from this subscription. - * @param handler A function that will receive the event `result` object. - * @returns The Subscription object, allowing for chaining. + * Attaches a handler function to be called for each event. + * + * When a handler is attached, any buffered events will be passed to it sequentially. + * Subsequent events will be passed directly as they arrive. + * + * @param {(data: T) => void} handler - The function to call with event data. */ - public on(handler: (result: T) => void) { - if (this.isUnsubscribed) { - throw new Error('Subscription has been unsubscribed.'); + public on(handler: (data: T) => void): void { + if (this.handler) { + // To avoid complexity, we only allow one handler at a time. + // Users can implement their own multi-handler logic if needed. + throw new Error('A handler is already attached to this subscription.'); } - this.listeners.push(handler); - // When a handler is attached for the first time, process the buffer - if (this.buffer.length > 0) { - this.buffer.forEach((bufferedResult) => handler(bufferedResult)); - this.buffer = []; // Clear buffer + this.handler = handler; + + // Process buffer + while (this.buffer.length > 0) { + const event = this.buffer.shift(); + if (event) { + this.handler(event); + } } - return this; // Allow chaining } /** - * Unsubscribes from the node and cleans up local resources. - * @returns A promise that resolves to `true` if the unsubscription was successful. + * Sends an unsubscribe request to the node and cleans up local resources. + * @returns {Promise} A Promise that resolves to `true` if the unsubscription was successful. */ public async unsubscribe(): Promise { - if (this.isUnsubscribed) { - return true; + if (this._isClosed) { + return true; // Already unsubscribed, treat as success. } - - // Immediately mark as unsubscribed and clean up local resources - // to prevent memory leaks, regardless of the server's response. - this.isUnsubscribed = true; - this.listeners = []; - this.buffer = []; - this.channel.removeSubscription(this.id); // Notify channel to remove it - - try { - // Attempt to inform the server, but the client-side cleanup is already done. - const success = await this.channel.unsubscribe(this.id); - return success; - } catch (error) { - logger.error(`Error unsubscribing from subscription ${this.id}:`, error); - // Return false as the server-side unsubscription failed. - return false; + const success = await this.channel.unsubscribe(this.id); + if (success) { + this._isClosed = true; + this.channel.removeSubscription(this.id); + this.events.emit('unsubscribe', undefined); + this.events.clear(); // Clean up all listeners } + return success; } } diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index 299424dcc..fe2c0a5ae 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -11,6 +11,8 @@ import type { import { BigNumberish, SubscriptionBlockIdentifier } from '../../types'; import { JRPC } from '../../types/api'; import { WebSocketEvent } from '../../types/api/jsonrpc'; +import { EventEmitter } from '../../utils/eventEmitter'; +import { TimeoutError, WebSocketNotConnectedError } from '../../utils/errors'; import WebSocket from '../../utils/connect/ws'; import { stringify } from '../../utils/json'; import { bigNumberishArrayToHexadecimalStringArray, toHex } from '../../utils/num'; @@ -19,19 +21,40 @@ import { config } from '../../global/config'; import { logger } from '../../global/logger'; import { Subscription } from './subscription'; +/** + * Options for configuring the automatic reconnection behavior of the WebSocketChannel. + */ +export type ReconnectOptions = { + /** + * The number of retries to attempt before giving up. + * @default 5 + */ + retries?: number; + /** + * The initial delay in milliseconds before the first retry. + * This delay will be doubled for each subsequent retry (exponential backoff). + * @default 2000 + */ + delay?: number; +}; + +/** + * Options for configuring the WebSocketChannel. + */ export type WebSocketOptions = { /** - * websocket node url address - * @example 'ws://www.host.com/path' - * @default public websocket enabled starknet node + * The URL of the WebSocket endpoint of the Starknet node. + * @example 'ws://localhost:9545' */ nodeUrl?: string; /** - * This parameter should be used when working in an environment without native WebSocket support by providing - * an equivalent WebSocket object that conforms to the protocol, e.g. from the 'isows' and/or 'ws' modules - * * https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket#protocols . - * * https://www.rfc-editor.org/rfc/rfc6455.html#section-1 . - * @default WebSocket + * This parameter can be used to provide a custom WebSocket implementation. + * This is useful in environments where the global WebSocket object is not available (e.g., Node.js). + * @example + * ```typescript + * import WebSocket from 'ws'; + * const channel = new WebSocketChannel({ nodeUrl: '...', websocket: WebSocket }); + * ``` */ websocket?: WebSocket; /** @@ -39,20 +62,58 @@ export type WebSocketOptions = { * @default 1000 */ maxBufferSize?: number; + /** + * Whether to automatically reconnect when the connection is lost. + * @default true + */ + autoReconnect?: boolean; + /** + * Options for the automatic reconnection behavior. + */ + reconnectOptions?: ReconnectOptions; + /** + * The timeout in milliseconds for a `sendReceive` call. + * @default 60000 + */ + requestTimeout?: number; +}; + +type WebSocketChannelEvents = { + open: Event; + close: CloseEvent; + message: MessageEvent; + error: Event; + unsubscribe: SUBSCRIPTION_ID; }; /** - * WebSocket channel provides communication with Starknet node over long-lived socket connection + * Manages a WebSocket connection to a Starknet node for receiving real-time updates. + * This class handles subscriptions, automatic reconnection, and request queueing. + * + * @example + * ```typescript + * const channel = new WebSocketChannel({ nodeUrl: 'YOUR_NODE_URL' }); + * await channel.waitForConnection(); + * + * const sub = await channel.subscribeNewHeads(); + * sub.on((data) => { + * console.log('New Block:', data); + * }); + * + * // ... later + * await sub.unsubscribe(); + * channel.disconnect(); + * ``` */ export class WebSocketChannel { /** - * WebSocket RPC Node URL - * @example 'wss://starknet-node.io/rpc/v0_8' + * The URL of the WebSocket RPC Node. + * @example 'wss://starknet-sepolia.public.blastapi.io/rpc/v0_8' */ public nodeUrl: string; /** - * ws library object + * The underlying WebSocket instance. */ public websocket: WebSocket; @@ -61,65 +122,36 @@ export class WebSocketChannel { private readonly maxBufferSize: number; - /** - * An array of listeners to be called when the WebSocket connection is opened. - * This is useful for monitoring the connection's health and state. - * @example - * ```typescript - * webSocketChannel.onOpen.push((ev) => console.log('Connection opened!', ev)); - * ``` - */ - public onOpen: Array<(this: WebSocketChannel, ev: Event) => any> = []; + private readonly autoReconnect: boolean; - /** - * An array of listeners to be called when the WebSocket connection is closed. - * This is useful for monitoring the connection's health and state. - * @example - * ```typescript - * webSocketChannel.onClose.push((ev) => console.log('Connection closed!', ev)); - * ``` - */ - public onClose: Array<(this: WebSocketChannel, ev: CloseEvent) => any> = []; + private readonly reconnectOptions: Required; - /** - * An array of listeners for receiving ALL raw messages from the WebSocket. - * This is an advanced feature and not needed for standard RPC calls or subscriptions. - * Use this as an "escape hatch" for debugging or handling non-standard, custom node events. - * @example - * ```typescript - * webSocketChannel.onMessage.push((ev) => console.log('Raw message received:', ev.data)); - * ``` - */ - public onMessage: Array<(this: WebSocketChannel, ev: MessageEvent) => any> = []; + private readonly requestTimeout: number; - /** - * An array of listeners to be called when a WebSocket error occurs. - * This is useful for logging and reacting to connection failures. - * @example - * ```typescript - * webSocketChannel.onError.push((ev) => console.error('A WebSocket error occurred:', ev)); - * ``` - */ - public onError: Array<(this: WebSocketChannel, ev: Event) => any> = []; + private isReconnecting = false; - /** - * An array of listeners to be called when a subscription is successfully unsubscribed. - * The listener will receive the ID of the unsubscribed subscription. - * @example - * ```typescript - * webSocketChannel.onUnsubscribe.push((id) => console.log(`Unsubscribed from ${id}`)); - * ``` - */ - public onUnsubscribe: Array<(this: WebSocketChannel, _subscriptionId: SUBSCRIPTION_ID) => any> = - []; + private reconnectAttempts = 0; + + private userInitiatedClose = false; + + private reconnectTimeoutId: NodeJS.Timeout | null = null; + + private requestQueue: Array<{ + method: string; + params?: object; + resolve: (value: any) => void; + reject: (reason?: any) => void; + }> = []; - private openListener = (ev: Event) => this.onOpen.forEach((h) => h.call(this, ev)); + private events = new EventEmitter(); + + private openListener = (ev: Event) => this.events.emit('open', ev); private closeListener = this.onCloseProxy.bind(this); private messageListener = this.onMessageProxy.bind(this); - private errorListener = (ev: Event) => this.onError.forEach((h) => h.call(this, ev)); + private errorListener = (ev: Event) => this.events.emit('error', ev); /** * JSON RPC latest sent message id @@ -128,15 +160,21 @@ export class WebSocketChannel { private sendId: number = 0; /** - * Construct class and event listeners - * @param options WebSocketOptions + * Creates an instance of WebSocketChannel. + * @param {WebSocketOptions} options - The options for configuring the channel. */ constructor(options: WebSocketOptions = {}) { // provided existing websocket const nodeUrl = options.nodeUrl || 'http://localhost:3000 '; this.nodeUrl = options.websocket ? options.websocket.url : nodeUrl; - this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); this.maxBufferSize = options.maxBufferSize ?? 1000; + this.autoReconnect = options.autoReconnect ?? true; + this.reconnectOptions = { + retries: options.reconnectOptions?.retries ?? 5, + delay: options.reconnectOptions?.delay ?? 2000, + }; + this.requestTimeout = options.requestTimeout ?? 60000; + this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); this.websocket.addEventListener('open', this.openListener); this.websocket.addEventListener('close', this.closeListener); @@ -153,16 +191,19 @@ export class WebSocketChannel { } /** - * Send data over open ws connection - * * this would only send data on the line without awaiting 'response message' - * @example - * ```typescript - * const sentId = await this.send('starknet_method', params); - * ``` + * Sends a JSON-RPC request over the WebSocket connection without waiting for a response. + * This is a low-level method. Prefer `sendReceive` for most use cases. + * @param {string} method - The RPC method name. + * @param {object} [params] - The parameters for the RPC method. + * @param {number} [id] - A specific request ID. If not provided, an auto-incrementing ID is used. + * @returns {number} The ID of the sent request. + * @throws {WebSocketNotConnectedError} If the WebSocket is not connected. */ public send(method: string, params?: object, id?: number) { if (!this.isConnected()) { - throw Error('WebSocketChannel.send() fail due to socket disconnected'); + throw new WebSocketNotConnectedError( + 'WebSocketChannel.send() fail due to socket disconnected' + ); } const usedId = this.idResolver(id); const rpcRequestBody: JRPC.RequestBody = { @@ -177,34 +218,48 @@ export class WebSocketChannel { } /** - * Send request and receive response over ws line - * This method abstract ws messages into request/response model - * @param method rpc method name - * @param params rpc method parameters - * @example - * ```typescript - * const response = await this.sendReceive('starknet_method', params); - * ``` + * Sends a JSON-RPC request and returns a Promise that resolves with the result. + * This method abstracts the request/response cycle over WebSockets. + * If the connection is lost, it will queue the request and send it upon reconnection. + * @template T - The expected type of the result. + * @param {string} method - The RPC method name. + * @param {object} [params] - The parameters for the RPC method. + * @returns {Promise} A Promise that resolves with the RPC response result. + * @throws {TimeoutError} If the request does not receive a response within the configured `requestTimeout`. + * @throws {WebSocketNotConnectedError} If the WebSocket is not connected and auto-reconnect is disabled. */ public sendReceive(method: string, params?: object): Promise { + // If we are in the process of reconnecting, or if we are disconnected but expect to reconnect, queue the request. + if ( + this.isReconnecting || + (!this.isConnected() && this.autoReconnect && !this.userInitiatedClose) + ) { + logger.info(`WebSocket: Connection unavailable, queueing request: ${method}`); + return new Promise((resolve, reject) => { + this.requestQueue.push({ method, params, resolve, reject }); + }); + } + const sendId = this.send(method, params); return new Promise((resolve, reject) => { + let timeoutId: NodeJS.Timeout; + if (!this.websocket || this.websocket.readyState !== WebSocket.OPEN) { - reject(new Error('WebSocket not available or not connected.')); - return; // Exit after rejecting + reject(new WebSocketNotConnectedError('WebSocket not available or not connected.')); + return; } - // Declare errorHandler first so it can be referenced by messageHandler for cleanup - let errorHandler: (event: Event) => void; const messageHandler = (event: MessageEvent) => { if (typeof event.data !== 'string') { console.warn('WebSocket received non-string message data:', event.data); - return; // Ignore non-string data + return; } const message: JRPC.ResponseBody = JSON.parse(event.data); if (message.id === sendId) { + clearTimeout(timeoutId); this.websocket.removeEventListener('message', messageHandler); + // eslint-disable-next-line @typescript-eslint/no-use-before-define this.websocket.removeEventListener('error', errorHandler); if ('result' in message) { @@ -217,9 +272,10 @@ export class WebSocketChannel { } }; - errorHandler = (event: Event) => { + const errorHandler = (event: Event) => { + clearTimeout(timeoutId); this.websocket.removeEventListener('message', messageHandler); - this.websocket.removeEventListener('error', errorHandler); // It removes itself here + this.websocket.removeEventListener('error', errorHandler); reject( new Error( `WebSocket error during ${method} (id: ${sendId}): ${event.type || 'Unknown error'}` @@ -229,22 +285,37 @@ export class WebSocketChannel { this.websocket.addEventListener('message', messageHandler); this.websocket.addEventListener('error', errorHandler); + + timeoutId = setTimeout(() => { + // Clean up listeners + this.websocket.removeEventListener('message', messageHandler); + this.websocket.removeEventListener('error', errorHandler); + reject( + new TimeoutError( + `Request ${method} (id: ${sendId}) timed out after ${this.requestTimeout}ms` + ) + ); + }, this.requestTimeout); }); } /** - * Helper to check connection is open + * Checks if the WebSocket connection is currently open. + * @returns {boolean} `true` if the connection is open, `false` otherwise. */ public isConnected() { return this.websocket.readyState === WebSocket.OPEN; } /** - * await while websocket is connected - * * could be used to block the flow until websocket is open + * Returns a Promise that resolves when the WebSocket connection is open. + * Can be used to block execution until the connection is established. + * @returns {Promise} A Promise that resolves with the WebSocket's `readyState` when connected. * @example * ```typescript - * const readyState = await webSocketChannel.waitForConnection(); + * const channel = new WebSocketChannel({ nodeUrl: '...' }); + * await channel.waitForConnection(); + * console.log('Connected!'); * ``` */ public async waitForConnection(): Promise { @@ -263,18 +334,23 @@ export class WebSocketChannel { } /** - * Disconnect the WebSocket connection, optionally using code as the the WebSocket connection close code and reason as the the WebSocket connection close reason. + * Closes the WebSocket connection. + * This method is user-initiated and will prevent automatic reconnection for this closure. + * @param {number} [code] - The WebSocket connection close code. + * @param {string} [reason] - The WebSocket connection close reason. */ public disconnect(code?: number, reason?: string) { + if (this.reconnectTimeoutId) { + clearTimeout(this.reconnectTimeoutId); + this.reconnectTimeoutId = null; + } this.websocket.close(code, reason); + this.userInitiatedClose = true; } /** - * await while websocket is disconnected - * @example - * ```typescript - * const readyState = await webSocketChannel.waitForDisconnection(); - * ``` + * Returns a Promise that resolves when the WebSocket connection is closed. + * @returns {Promise} A Promise that resolves with the WebSocket's `readyState` or a `CloseEvent` when disconnected. */ public async waitForDisconnection(): Promise { // Wait websocket to disconnect @@ -290,44 +366,50 @@ export class WebSocketChannel { } /** - * Unsubscribe from starknet subscription - * @param subscriptionId + * Unsubscribes from a Starknet subscription. + * It is recommended to use the `unsubscribe()` method on the `Subscription` object instead. + * @internal + * @param {SUBSCRIPTION_ID} subscriptionId - The ID of the subscription to unsubscribe from. + * @returns {Promise} A Promise that resolves with `true` if the unsubscription was successful. */ public async unsubscribe(subscriptionId: SUBSCRIPTION_ID) { const status = await this.sendReceive('starknet_unsubscribe', { subscription_id: subscriptionId, }); if (status) { - this.onUnsubscribe.forEach((h) => h.call(this, subscriptionId)); + this.events.emit('unsubscribe', subscriptionId); } return status; } /** - * await while subscription is unsubscribed - * @param targetId The ID of the subscription to wait for. + * Returns a Promise that resolves when a specific subscription is successfully unsubscribed. + * @param {SUBSCRIPTION_ID} targetId - The ID of the subscription to wait for. + * @returns {Promise} * @example * ```typescript - * await webSocketChannel.waitForUnsubscription(subscription.id); + * await channel.waitForUnsubscription(mySubscription.id); + * console.log('Successfully unsubscribed.'); * ``` */ public waitForUnsubscription(targetId: SUBSCRIPTION_ID): Promise { return new Promise((resolve) => { const listener = (unsubId: SUBSCRIPTION_ID) => { if (unsubId === targetId) { - // remove this specific listener from the array - this.onUnsubscribe = this.onUnsubscribe.filter((l) => l !== listener); + this.events.off('unsubscribe', listener); resolve(); } }; - this.onUnsubscribe.push(listener); + this.events.on('unsubscribe', listener); }); } /** - * Reconnect re-create this.websocket instance + * Manually initiates a reconnection attempt. + * This creates a new WebSocket instance and re-establishes listeners. */ public reconnect() { + this.userInitiatedClose = false; this.websocket = new WebSocket(this.nodeUrl); this.websocket.addEventListener('open', this.openListener); @@ -336,12 +418,86 @@ export class WebSocketChannel { this.websocket.addEventListener('error', this.errorListener); } + private _processRequestQueue(): void { + logger.info(`WebSocket: Processing ${this.requestQueue.length} queued requests.`); + while (this.requestQueue.length > 0) { + const { method, params, resolve, reject } = this.requestQueue.shift()!; + this.sendReceive(method, params).then(resolve).catch(reject); + } + } + + private async _restoreSubscriptions(): Promise { + const oldSubscriptions = Array.from(this.activeSubscriptions.values()); + this.activeSubscriptions.clear(); + + const restorePromises = oldSubscriptions.map(async (sub) => { + try { + const newSubId = await this.sendReceive(sub.method, sub.params); + // eslint-disable-next-line no-param-reassign + sub.id = newSubId; // Update the subscription with the new ID + this.activeSubscriptions.set(newSubId, sub); + logger.info(`Subscription ${sub.method} restored with new ID: ${newSubId}`); + } catch (error) { + logger.error(`Failed to restore subscription ${sub.method}:`, error); + // The subscription is not added back to activeSubscriptions if it fails + } + }); + + await Promise.all(restorePromises); + } + + private _startReconnect() { + if (this.isReconnecting || !this.autoReconnect) { + return; + } + + this.isReconnecting = true; + this.reconnectAttempts = 0; + + const tryReconnect = () => { + if (this.reconnectAttempts >= this.reconnectOptions.retries) { + logger.error('WebSocket: Maximum reconnection retries reached. Giving up.'); + this.isReconnecting = false; + return; + } + + this.reconnectAttempts += 1; + logger.info( + `WebSocket: Connection lost. Attempting to reconnect... (${this.reconnectAttempts}/${this.reconnectOptions.retries})` + ); + + this.reconnect(); // Attempt to reconnect + + this.websocket.onopen = async () => { + logger.info('WebSocket: Reconnection successful.'); + this.isReconnecting = false; + this.reconnectAttempts = 0; + await this._restoreSubscriptions(); + this._processRequestQueue(); + // Manually trigger the onOpen listeners as the original event is gone. + this.events.emit('open', new Event('open')); + }; + + this.websocket.onerror = () => { + const delay = this.reconnectOptions.delay * 2 ** (this.reconnectAttempts - 1); + logger.info(`WebSocket: Reconnect attempt failed. Retrying in ${delay}ms.`); + this.reconnectTimeoutId = setTimeout(tryReconnect, delay); + }; + }; + + tryReconnect(); + } + private onCloseProxy(ev: CloseEvent) { this.websocket.removeEventListener('open', this.openListener); this.websocket.removeEventListener('close', this.closeListener); this.websocket.removeEventListener('message', this.messageListener); this.websocket.removeEventListener('error', this.errorListener); - this.onClose.forEach((h) => h.call(this, ev)); + this.events.emit('close', ev); + + if (!this.userInitiatedClose) { + this._startReconnect(); + } } private onMessageProxy(event: MessageEvent) { @@ -379,75 +535,89 @@ export class WebSocketChannel { } // Call the general onMessage handler if provided by the user, for all messages. - this.onMessage.forEach((h) => h.call(this, event)); + this.events.emit('message', event); } /** - * subscribe to new block heads + * Subscribes to new block headers. + * @param {SubscriptionBlockIdentifier} [blockIdentifier] - The block to start receiving notifications from. Defaults to 'latest'. + * @returns {Promise>} A Promise that resolves with a `Subscription` object for new block headers. */ public async subscribeNewHeads( blockIdentifier?: SubscriptionBlockIdentifier ): Promise> { - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - const subId = await this.sendReceive('starknet_subscribeNewHeads', { - ...{ block_id }, - }); - const subscription = new Subscription(this, subId, this.maxBufferSize); + const method = 'starknet_subscribeNewHeads'; + const params = { + block_id: blockIdentifier ? new Block(blockIdentifier).identifier : undefined, + }; + const subId = await this.sendReceive(method, params); + const subscription = new Subscription(this, method, params, subId, this.maxBufferSize); this.activeSubscriptions.set(subId, subscription); return subscription; } /** - * subscribe to 'starknet events' + * Subscribes to events matching a given filter. + * @param {BigNumberish} [fromAddress] - The contract address to filter by. + * @param {string[][]} [keys] - The event keys to filter by. + * @param {SubscriptionBlockIdentifier} [blockIdentifier] - The block to start receiving notifications from. Defaults to 'latest'. + * @returns {Promise>} A Promise that resolves with a `Subscription` object for the specified events. */ public async subscribeEvents( fromAddress?: BigNumberish, keys?: string[][], blockIdentifier?: SubscriptionBlockIdentifier ): Promise> { - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - const subId = await this.sendReceive('starknet_subscribeEvents', { - ...{ from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined }, - ...{ keys }, - ...{ block_id }, - }); - const subscription = new Subscription(this, subId, this.maxBufferSize); + const method = 'starknet_subscribeEvents'; + const params = { + from_address: fromAddress !== undefined ? toHex(fromAddress) : undefined, + keys, + block_id: blockIdentifier ? new Block(blockIdentifier).identifier : undefined, + }; + const subId = await this.sendReceive(method, params); + const subscription = new Subscription(this, method, params, subId, this.maxBufferSize); this.activeSubscriptions.set(subId, subscription); return subscription; } /** - * subscribe to transaction status + * Subscribes to status updates for a specific transaction. + * @param {BigNumberish} transactionHash - The hash of the transaction to monitor. + * @param {SubscriptionBlockIdentifier} [blockIdentifier] - The block context. Not typically required. + * @returns {Promise>} A Promise that resolves with a `Subscription` object for the transaction's status. */ public async subscribeTransactionStatus( transactionHash: BigNumberish, blockIdentifier?: SubscriptionBlockIdentifier ): Promise> { - const transaction_hash = toHex(transactionHash); - const block_id = blockIdentifier ? new Block(blockIdentifier).identifier : undefined; - const subId = await this.sendReceive('starknet_subscribeTransactionStatus', { - transaction_hash, - ...{ block_id }, - }); - const subscription = new Subscription(this, subId, this.maxBufferSize); + const method = 'starknet_subscribeTransactionStatus'; + const params = { + transaction_hash: toHex(transactionHash), + block_id: blockIdentifier ? new Block(blockIdentifier).identifier : undefined, + }; + const subId = await this.sendReceive(method, params); + const subscription = new Subscription(this, method, params, subId, this.maxBufferSize); this.activeSubscriptions.set(subId, subscription); return subscription; } /** - * subscribe to pending transactions (mempool) + * Subscribes to pending transactions. + * @param {boolean} [transactionDetails] - If `true`, the full transaction details are included. Defaults to `false` (hash only). + * @param {BigNumberish[]} [senderAddress] - An array of sender addresses to filter by. + * @returns {Promise>} A Promise that resolves with a `Subscription` object for pending transactions. */ public async subscribePendingTransaction( transactionDetails?: boolean, senderAddress?: BigNumberish[] ): Promise> { - const subId = await this.sendReceive('starknet_subscribePendingTransactions', { - ...{ transaction_details: transactionDetails }, - ...{ - sender_address: senderAddress && bigNumberishArrayToHexadecimalStringArray(senderAddress), - }, - }); - const subscription = new Subscription(this, subId, this.maxBufferSize); + const method = 'starknet_subscribePendingTransactions'; + const params = { + transaction_details: transactionDetails, + sender_address: senderAddress && bigNumberishArrayToHexadecimalStringArray(senderAddress), + }; + const subId = await this.sendReceive(method, params); + const subscription = new Subscription(this, method, params, subId, this.maxBufferSize); this.activeSubscriptions.set(subId, subscription); return subscription; } @@ -459,4 +629,28 @@ export class WebSocketChannel { public removeSubscription(id: SUBSCRIPTION_ID) { this.activeSubscriptions.delete(id); } + + /** + * Adds a listener for a given event. + * @param event The event name. + * @param listener The listener function to add. + */ + public on( + event: K, + listener: (data: WebSocketChannelEvents[K]) => void + ): void { + this.events.on(event, listener); + } + + /** + * Removes a listener for a given event. + * @param event The event name. + * @param listener The listener function to remove. + */ + public off( + event: K, + listener: (data: WebSocketChannelEvents[K]) => void + ): void { + this.events.off(event, listener); + } } diff --git a/src/provider/index.ts b/src/provider/index.ts index 027a54085..c4dc08947 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,7 +1,7 @@ import { RpcProvider } from './rpc'; export { RpcProvider as Provider } from './extensions/default'; // backward-compatibility -export * from '../utils/errors'; +export { LibraryError, RpcError, starknetError, GatewayError } from '../utils/errors'; export * from './interface'; export * from './extensions/default'; diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts index 2207a6049..b73bdd08f 100644 --- a/src/utils/errors/index.ts +++ b/src/utils/errors/index.ts @@ -3,6 +3,8 @@ import { RPC, RPC_ERROR, RPC_ERROR_SET } from '../../types'; import { stringify } from '../json'; import rpcErrors from './rpc'; +export * from './ws'; + // eslint-disable-next-line max-classes-per-file export function fixStack(target: Error, fn: Function = target.constructor) { const { captureStackTrace } = Error as any; @@ -76,3 +78,31 @@ export class RpcError extends LibraryE return rpcErrors[typeName] === this.code; } } + +// eslint-disable-next-line max-classes-per-file +export class starknetError extends Error { + name!: string; + + constructor(message?: string) { + super(message); + // set error name as constructor name, make it not enumerable to keep native Error behavior + // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new.target#new.target_in_constructors + // see https://github.com/adriengibrat/ts-custom-error/issues/30 + Object.defineProperty(this, 'name', { + value: new.target.name, + enumerable: false, + configurable: true, + }); + // fix the extended error prototype chain + // because typescript __extends implementation can't + // see https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work + fixProto(this, new.target.prototype); + // try to remove constructor from stack trace + fixStack(this); + } +} + +/** + * @deprecated replaced by Cairo.getCompiledClass(...) + */ +export class GatewayError extends starknetError {} diff --git a/src/utils/errors/ws.ts b/src/utils/errors/ws.ts new file mode 100644 index 000000000..3e7370342 --- /dev/null +++ b/src/utils/errors/ws.ts @@ -0,0 +1,25 @@ +/* eslint-disable max-classes-per-file */ + +/** + * Thrown when a WebSocket request does not receive a response within the configured timeout period. + * + * @property {string} name - The name of the error, always 'TimeoutError'. + */ +export class TimeoutError extends Error { + constructor(message: string) { + super(message); + this.name = 'TimeoutError'; + } +} + +/** + * Thrown when an operation is attempted on a WebSocket that is not connected. + * + * @property {string} name - The name of the error, always 'WebSocketNotConnectedError'. + */ +export class WebSocketNotConnectedError extends Error { + constructor(message: string) { + super(message); + this.name = 'WebSocketNotConnectedError'; + } +} diff --git a/src/utils/eventEmitter.ts b/src/utils/eventEmitter.ts new file mode 100644 index 000000000..8823fc073 --- /dev/null +++ b/src/utils/eventEmitter.ts @@ -0,0 +1,30 @@ +/* eslint-disable max-classes-per-file */ +type Listener = (data: T) => void; + +export class EventEmitter> { + private listeners: { [K in keyof T]?: Listener[] } = {}; + + public on(event: K, listener: Listener): void { + if (!this.listeners[event]) { + this.listeners[event] = []; + } + this.listeners[event]!.push(listener); + } + + public off(event: K, listener: Listener): void { + if (!this.listeners[event]) { + return; + } + this.listeners[event] = this.listeners[event]!.filter((l) => l !== listener); + } + + public emit(event: K, data: T[K]): void { + if (this.listeners[event]) { + this.listeners[event]!.forEach((listener) => listener(data)); + } + } + + public clear(): void { + this.listeners = {}; + } +} diff --git a/www/docs/guides/websocket_channel.md b/www/docs/guides/websocket_channel.md index 636d1fc7b..56c5882ff 100644 --- a/www/docs/guides/websocket_channel.md +++ b/www/docs/guides/websocket_channel.md @@ -2,89 +2,137 @@ sidebar_position: 3 --- -# Channel (WebSocket, Rpc) +# WebSocket Channel -Channel is the lowest purest object you can use to interact with the network. -Channel represent implementation of the Starknet specification in it's strictly defined form. +The `WebSocketChannel` provides a robust, real-time connection to a Starknet RPC Node, enabling you to subscribe to events and receive updates as they happen. It's designed for production use with features like automatic reconnection, request queueing, and a modern subscription management API. -## WebSocket Channel +Ensure that you are using a node that supports the required RPC spec (e.g., v0.8.0). -WebSocket channel provide convenient way to establish websocket connection to the [Starknet RPC Node](https://www.starknet.io/fullnodes-rpc-services/). +## Key Features -Ensure that you are using node supporting the required RPC spec >= v0.8.0. Details regarding Starknet Nodes and supported RPC versions could be found on each node github/page. +- **Modern API**: Uses a `Subscription` object to manage event streams. +- **Automatic Reconnection**: Automatically detects connection drops and reconnects with an exponential backoff strategy. +- **Request Queueing**: Queues any requests made while the connection is down and executes them upon reconnection. +- **Event Buffering**: Buffers events for a subscription if no handler is attached, preventing event loss. +- **Custom Errors**: Throws specific, catchable errors like `TimeoutError` for more reliable error handling. -Websocket Channel implements specification methods defined by [@starknet-io/types-js](https://github.com/starknet-io/types-js/blob/b7d38ca30a1def28e89370068efff81b3a3062b7/src/api/methods.ts#L421) +## Importing -### Import +To get started, import the necessary classes and types from the `starknet` library. ```typescript -import { WebSocketChannel, Subscription } from 'starknet'; +import { + WebSocketChannel, + WebSocketOptions, + Subscription, + TimeoutError, + WebSocketNotConnectedError, +} from 'starknet'; ``` -### Create instance +## Creating a WebSocket Channel + +Instantiate `WebSocketChannel` with your node's WebSocket URL. ```typescript -// create new ws channel -const webSocketChannel = new WebSocketChannel({ - nodeUrl: 'wss://sepolia-pathfinder-rpc.server.io/rpc/v0_8', - maxBufferSize: 200, // Optional: default is 1000 +const channel = new WebSocketChannel({ + nodeUrl: 'wss://your-starknet-node/rpc/v0_8', }); -// ensure ws channel is open -await webSocketChannel.waitForConnection(); - -// ... use webSocketChannel +// It's good practice to wait for the initial connection +await channel.waitForConnection(); ``` -If the environment doesn't have a detectable global `WebSocket`, an appropriate `WebSocket` implementation should be used and set with the `websocket` constructor parameter. +If you are in an environment without a native `WebSocket` object (like Node.js), you can provide a custom implementation (e.g., from the `ws` library). ```typescript -import { WebSocket } from 'ws'; +import WebSocket from 'ws'; -const webSocketChannel = new WebSocketChannel({ - websocket: new WebSocket('wss://sepolia-pathfinder-rpc.server.io/rpc/v0_8'), +const channel = new WebSocketChannel({ + nodeUrl: '...', + websocket: WebSocket, // Provide the implementation class }); ``` -### Usage +### Advanced Configuration + +You can customize the channel's behavior with `WebSocketOptions`. -When you call a subscription method like `subscribeNewHeads`, it now returns a `Promise` that resolves with a `Subscription` object. This object is your handle to that specific subscription. +```typescript +const options: WebSocketOptions = { + nodeUrl: '...', + autoReconnect: true, // Default: true + reconnectOptions: { + retries: 5, // Default: 5 + delay: 2000, // Default: 2000ms + }, + requestTimeout: 60000, // Default: 60000ms + maxBufferSize: 1000, // Default: 1000 events per subscription +}; + +const channel = new WebSocketChannel(options); +``` -You can attach a listener to it using the `.on()` method and stop listening with the `.unsubscribe()` method. This new model allows you to have multiple, independent subscriptions to the same type of event. +## Subscribing to Events -Here is a complete example: +When you call a subscription method (e.g., `subscribeNewHeads`), it returns a `Promise` that resolves with a `Subscription` object. This object is your handle to that specific event stream. + +You attach a listener with `.on()` and stop listening with `.unsubscribe()`. ```typescript -// 1. Subscribe to an event. This returns a Subscription object. -const subscription = await webSocketChannel.subscribeNewHeads(); - -// 2. Attach a handler to the `.on()` method to process incoming events. -subscription.on((data) => { - console.log('New Head:', data); - // After receiving one event, we can choose to unsubscribe. - unsubscribeFromEvents(); +// 1. Subscribe to an event stream. +const sub: Subscription = await channel.subscribeNewHeads(); + +// 2. Attach a handler to process incoming data. +sub.on((data) => { + console.log('Received new block header:', data.block_number); }); -// 3. To stop receiving events, call the .unsubscribe() method. -async function unsubscribeFromEvents() { - const success = await subscription.unsubscribe(); - console.log('Unsubscribed successfully:', success); -} +// 3. When you're done, unsubscribe. +// This is automatically handled if the channel disconnects and restores the subscription. +// You only need to call this when you explicitly want to stop listening. +await sub.unsubscribe(); ``` -### Buffering +### Event Buffering + +If you `await` a subscription but don't immediately attach an `.on()` handler, the `Subscription` object will buffer incoming events. Once you attach a handler, all buffered events will be delivered in order before any new events are processed. This prevents event loss during asynchronous setup. -If you subscribe to an event but don't attach a handler with `.on()` immediately, the `Subscription` object will buffer incoming events for you. When you eventually attach a handler, all buffered events will be passed to it in order before any new events are processed. +The buffer size is limited by `maxBufferSize` in the channel options. If the buffer is full, the oldest events are dropped. -To prevent memory overflow, the buffer has a maximum size. You can configure this with the `maxBufferSize` option in the `WebSocketChannel` constructor (default is 1000). If the buffer becomes full, the oldest events will be dropped. +## Automatic Reconnection and Queueing + +The channel is designed to be resilient. If the connection drops, it will automatically try to reconnect. While reconnecting: + +- Any API calls (e.g., `sendReceive`, `subscribeNewHeads`) will be queued. +- Once the connection is restored, the queue will be processed automatically. +- All previously active subscriptions will be **automatically re-subscribed**. You do not need to manually handle this. + +## Error Handling + +The channel throws specific errors, allowing for precise error handling. + +```typescript +try { + const result = await channel.sendReceive('starknet_chainId'); +} catch (e) { + if (e instanceof TimeoutError) { + console.error('The request timed out!'); + } else if (e instanceof WebSocketNotConnectedError) { + console.error('The WebSocket is not connected.'); + } else { + console.error('An unknown error occurred:', e); + } +} +``` -### Available Subscription Methods +## Available Subscription Methods -You can subscribe to different types of events using the following methods on the `WebSocketChannel` instance. Each returns a `Promise`. +Each of these methods returns a `Promise`. - `subscribeNewHeads` - `subscribeEvents` - `subscribeTransactionStatus` - `subscribePendingTransaction` -Complete API can be found on [websocket API section](/docs/next/API/classes/WebSocketChannel) +For more details, see the complete [API documentation](/docs/next/API/classes/WebSocketChannel). From 9958cd428d7fe2242e2499671d96b387a6cf259d Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Wed, 11 Jun 2025 19:22:09 +0200 Subject: [PATCH 11/16] test: subscription Restoration, Queuing for subscribes --- __tests__/WebSocketChannel.test.ts | 72 ++++++++++++++++++++++++++++-- 1 file changed, 69 insertions(+), 3 deletions(-) diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index 6b8460493..b3270531a 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -293,8 +293,7 @@ describe('WebSocketChannel Auto-Reconnection', () => { let hasReconnected = false; webSocketChannel.on('open', () => { if (hasReconnected) { - // 4. Test is done when reconnection is complete - done(); + // Reconnected. The promise from the queued sendReceive will resolve now. } else { // 1. First connection, now simulate a drop hasReconnected = true; @@ -302,10 +301,77 @@ describe('WebSocketChannel Auto-Reconnection', () => { // 2. Immediately try to send a request. It should be queued. webSocketChannel.sendReceive('starknet_chainId').then((result) => { - // 3. This assertion runs after reconnection and proves the queue was processed. + // 3. This assertion runs after reconnection, proving the queue was processed. expect(result).toBe(StarknetChainId.SN_SEPOLIA); + done(); // 4. Test is done when the queued request has been successfully processed. + }); + } + }); + }); + + test('should queue subscribe requests when reconnecting and process them after', (done) => { + jest.setTimeout(30000); // Allow time for reconnect and a new block event + + webSocketChannel = new WebSocketChannel({ + nodeUrl: TEST_WS_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + if (hasReconnected) { + // Reconnected. The promise from the queued subscribeNewHeads will resolve now. + } else { + // 1. First connection, now simulate a drop + hasReconnected = true; + webSocketChannel.websocket.close(); + + // 2. Immediately try to subscribe. The request should be queued. + webSocketChannel.subscribeNewHeads().then((sub) => { + // 3. This should only execute after reconnection. + expect(sub).toBeInstanceOf(Subscription); + expect(webSocketChannel.isConnected()).toBe(true); + + // 4. To prove it's a real subscription, wait for one event. + sub.on((data) => { + expect(data).toBeDefined(); + done(); + }); }); } }); }); + + test('should restore active subscriptions after an automatic reconnection', (done) => { + jest.setTimeout(30000); // Allow time for reconnect and new block + + webSocketChannel = new WebSocketChannel({ + nodeUrl: TEST_WS_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let connectionCount = 0; + + const eventHandler = (data: any) => { + // The handler is called. If this is after the reconnection (connectionCount > 1), + // it proves the subscription was successfully restored. + if (connectionCount > 1) { + expect(data).toBeDefined(); + done(); + } + }; + + webSocketChannel.on('open', async () => { + connectionCount += 1; + if (connectionCount === 1) { + // First connection: set up the subscription + const sub = await webSocketChannel.subscribeNewHeads(); + sub.on(eventHandler); + // Now, simulate a drop + webSocketChannel.websocket.close(); + } + // On the second 'open' event (connectionCount === 2), the test will implicitly + // be waiting for the eventHandler to be called, which will resolve the test. + }); + }); }); From aea7f527eb4a881bc5172e511c96abd2affd36d5 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 16 Jun 2025 13:48:42 +0200 Subject: [PATCH 12/16] fix: cleanup, unit and tests, docs, errors --- __tests__/WebSocketChannel.test.ts | 550 +++++++++++++++------------ src/channel/ws/subscription.ts | 8 +- src/channel/ws/ws_0_8.ts | 56 ++- src/provider/index.ts | 2 +- src/utils/errors/index.ts | 38 +- src/utils/errors/ws.ts | 25 -- www/docs/guides/websocket_channel.md | 8 +- 7 files changed, 356 insertions(+), 331 deletions(-) delete mode 100644 src/utils/errors/ws.ts diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index b3270531a..a4a9a022b 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -1,157 +1,306 @@ /* eslint-disable no-underscore-dangle */ import { Provider, Subscription, WebSocketChannel } from '../src'; +import { logger } from '../src/global/logger'; import { StarknetChainId } from '../src/global/constants'; import { getTestAccount, getTestProvider, STRKtokenAddress, TEST_WS_URL } from './config/fixtures'; -describe('websocket specific endpoints - pathfinder test', () => { - // account provider - const provider = new Provider(getTestProvider()); - const account = getTestAccount(provider); +const describeIfWs = TEST_WS_URL ? describe : describe.skip; +const NODE_URL = TEST_WS_URL!; - // websocket - let webSocketChannel: WebSocketChannel; +describeIfWs('E2E WebSocket Tests', () => { + describe('websocket specific endpoints - pathfinder test', () => { + // account provider + const provider = new Provider(getTestProvider()); + const account = getTestAccount(provider); - beforeEach(async () => { - webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); - await webSocketChannel.waitForConnection(); - }); + // websocket + let webSocketChannel: WebSocketChannel; - afterEach(async () => { - if (webSocketChannel.isConnected()) { + beforeEach(async () => { + webSocketChannel = new WebSocketChannel({ nodeUrl: NODE_URL }); + await webSocketChannel.waitForConnection(); + }); + + afterEach(async () => { + if (webSocketChannel.isConnected()) { + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); + } + }); + + test('should throw an error when sending on a disconnected socket', async () => { + // This test uses its own channel to disable auto-reconnect and isolate the error behavior + const testChannel = new WebSocketChannel({ nodeUrl: NODE_URL, autoReconnect: false }); + await testChannel.waitForConnection(); + + testChannel.disconnect(); + await testChannel.waitForDisconnection(); + + // With autoReconnect: false, this should immediately throw, not queue. + await expect(testChannel.subscribeNewHeads()).rejects.toThrow( + 'WebSocketChannel.send() failed due to socket being disconnected' + ); + }); + + test('should allow manual reconnection after a user-initiated disconnect', async () => { + // This test uses the default channel from `beforeEach` which has autoReconnect: true webSocketChannel.disconnect(); await webSocketChannel.waitForDisconnection(); - } - }); - test('should throw an error when sending on a disconnected socket', async () => { - // This test uses its own channel to disable auto-reconnect and isolate the error behavior - const testChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL, autoReconnect: false }); - await testChannel.waitForConnection(); + // It should not have auto-reconnected because the disconnect was user-initiated + expect(webSocketChannel.isConnected()).toBe(false); - testChannel.disconnect(); - await testChannel.waitForDisconnection(); + // Now, manually reconnect + webSocketChannel.reconnect(); + await webSocketChannel.waitForConnection(); + expect(webSocketChannel.isConnected()).toBe(true); - // With autoReconnect: false, this should immediately throw, not queue. - await expect(testChannel.subscribeNewHeads()).rejects.toThrow( - 'WebSocketChannel.send() fail due to socket disconnected' - ); - }); + // To prove the connection is working, make a simple RPC call. + // This avoids the flakiness of creating and tearing down a real subscription. + const chainId = await webSocketChannel.sendReceive('starknet_chainId'); + expect(chainId).toBe(StarknetChainId.SN_SEPOLIA); + }); - test('should allow manual reconnection after a user-initiated disconnect', async () => { - // This test uses the default channel from `beforeEach` which has autoReconnect: true - webSocketChannel.disconnect(); - await webSocketChannel.waitForDisconnection(); + test('Test subscribeNewHeads', async () => { + const sub = await webSocketChannel.subscribeNewHeads(); + expect(sub).toBeInstanceOf(Subscription); + + let i = 0; + sub.on(async (result) => { + i += 1; + expect(result).toBeDefined(); + if (i === 2) { + const status = await sub.unsubscribe(); + expect(status).toBe(true); + } + }); + + await webSocketChannel.waitForUnsubscription(sub.id); + }); - // It should not have auto-reconnected because the disconnect was user-initiated - expect(webSocketChannel.isConnected()).toBe(false); + test('Test subscribeEvents', async () => { + const sub = await webSocketChannel.subscribeEvents(); + expect(sub).toBeInstanceOf(Subscription); + + let i = 0; + sub.on(async (result) => { + i += 1; + expect(result).toBeDefined(); + if (i === 5) { + const status = await sub.unsubscribe(); + expect(status).toBe(true); + } + }); + + await webSocketChannel.waitForUnsubscription(sub.id); + }); - // Now, manually reconnect - webSocketChannel.reconnect(); - await webSocketChannel.waitForConnection(); - expect(webSocketChannel.isConnected()).toBe(true); + test('Test subscribePendingTransaction', async () => { + const sub = await webSocketChannel.subscribePendingTransaction(true); + expect(sub).toBeInstanceOf(Subscription); + + let i = 0; + sub.on(async (result) => { + i += 1; + expect(result).toBeDefined(); + if (i === 5) { + const status = await sub.unsubscribe(); + expect(status).toBe(true); + } + }); + await webSocketChannel.waitForUnsubscription(sub.id); + }); - // To prove the connection is working, make a simple RPC call. - // This avoids the flakiness of creating and tearing down a real subscription. - const chainId = await webSocketChannel.sendReceive('starknet_chainId'); - expect(chainId).toBe(StarknetChainId.SN_SEPOLIA); + test('Test subscribeTransactionStatus', async () => { + const { transaction_hash } = await account.execute({ + contractAddress: STRKtokenAddress, + entrypoint: 'transfer', + calldata: [account.address, '10', '0'], + }); + + const sub = await webSocketChannel.subscribeTransactionStatus(transaction_hash); + expect(sub).toBeInstanceOf(Subscription); + + let i = 0; + sub.on(async (result) => { + i += 1; + expect(result).toBeDefined(); + if (i >= 2) { + const status = await sub.unsubscribe(); + expect(status).toBe(true); + } + }); + await webSocketChannel.waitForUnsubscription(sub.id); + }); }); - test('Test subscribeNewHeads', async () => { - const sub = await webSocketChannel.subscribeNewHeads(); - expect(sub).toBeInstanceOf(Subscription); - - let i = 0; - sub.on(async (result) => { - i += 1; - expect(result).toBeDefined(); - if (i === 2) { - const status = await sub.unsubscribe(); - expect(status).toBe(true); - } - }); + describe('websocket regular endpoints - pathfinder test', () => { + let webSocketChannel: WebSocketChannel; - await webSocketChannel.waitForUnsubscription(sub.id); - }); + beforeAll(async () => { + webSocketChannel = new WebSocketChannel({ nodeUrl: NODE_URL }); + expect(webSocketChannel.isConnected()).toBe(false); + const status = await webSocketChannel.waitForConnection(); + expect(status).toBe(WebSocket.OPEN); + }); - test('Test subscribeEvents', async () => { - const sub = await webSocketChannel.subscribeEvents(); - expect(sub).toBeInstanceOf(Subscription); - - let i = 0; - sub.on(async (result) => { - i += 1; - expect(result).toBeDefined(); - if (i === 5) { - const status = await sub.unsubscribe(); - expect(status).toBe(true); - } + afterAll(async () => { + expect(webSocketChannel.isConnected()).toBe(true); + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); }); - await webSocketChannel.waitForUnsubscription(sub.id); + test('regular rpc endpoint', async () => { + const response = await webSocketChannel.sendReceive('starknet_chainId'); + expect(response).toBe(StarknetChainId.SN_SEPOLIA); + }); }); - test('Test subscribePendingTransaction', async () => { - const sub = await webSocketChannel.subscribePendingTransaction(true); - expect(sub).toBeInstanceOf(Subscription); - - let i = 0; - sub.on(async (result) => { - i += 1; - expect(result).toBeDefined(); - if (i === 5) { - const status = await sub.unsubscribe(); - expect(status).toBe(true); + describe('WebSocketChannel Auto-Reconnection', () => { + let webSocketChannel: WebSocketChannel; + + afterEach(async () => { + // Ensure the channel is always disconnected after each test to prevent open handles. + if (webSocketChannel) { + webSocketChannel.disconnect(); + await webSocketChannel.waitForDisconnection(); } }); - await webSocketChannel.waitForUnsubscription(sub.id); - }); - test('Test subscribeTransactionStatus', async () => { - const { transaction_hash } = await account.execute({ - contractAddress: STRKtokenAddress, - entrypoint: 'transfer', - calldata: [account.address, '10', '0'], + test('should automatically reconnect on connection drop', (done) => { + // Set a very short reconnection delay for faster tests + webSocketChannel = new WebSocketChannel({ + nodeUrl: NODE_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + // This will be called once on initial connection, and a second time on reconnection. + if (hasReconnected) { + done(); // Test is successful if we get here + } else { + // This is the first connection, now we simulate the drop + hasReconnected = true; + webSocketChannel.websocket.close(); + } + }); }); - const sub = await webSocketChannel.subscribeTransactionStatus(transaction_hash); - expect(sub).toBeInstanceOf(Subscription); + test('sendReceive should time out if no response is received', async () => { + webSocketChannel = new WebSocketChannel({ + nodeUrl: NODE_URL, + requestTimeout: 100, // Set a short timeout for testing + }); + await webSocketChannel.waitForConnection(); - let i = 0; - sub.on(async (result) => { - i += 1; - expect(result).toBeDefined(); - if (i >= 2) { - const status = await sub.unsubscribe(); - expect(status).toBe(true); - } - }); - await webSocketChannel.waitForUnsubscription(sub.id); - }); -}); + // Spy on the 'send' method and prevent it from sending anything. + // This guarantees that we will never get a response and the timeout will be triggered. + const sendSpy = jest.spyOn(webSocketChannel.websocket, 'send').mockImplementation(() => {}); -describe('websocket regular endpoints - pathfinder test', () => { - let webSocketChannel: WebSocketChannel; + // We expect this promise to reject with a timeout error. + await expect( + webSocketChannel.sendReceive('some_method_that_will_never_get_a_response') + ).rejects.toThrow('timed out after 100ms'); - beforeAll(async () => { - webSocketChannel = new WebSocketChannel({ nodeUrl: TEST_WS_URL }); - expect(webSocketChannel.isConnected()).toBe(false); - const status = await webSocketChannel.waitForConnection(); - expect(status).toBe(WebSocket.OPEN); - }); + // Restore the original implementation for other tests + sendSpy.mockRestore(); + }); - afterAll(async () => { - expect(webSocketChannel.isConnected()).toBe(true); - webSocketChannel.disconnect(); - await webSocketChannel.waitForDisconnection(); - }); + test('should queue sendReceive requests when reconnecting and process them after', (done) => { + webSocketChannel = new WebSocketChannel({ + nodeUrl: NODE_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + if (hasReconnected) { + // Reconnected. The promise from the queued sendReceive will resolve now. + } else { + // 1. First connection, now simulate a drop + hasReconnected = true; + webSocketChannel.websocket.close(); + + // 2. Immediately try to send a request. It should be queued. + webSocketChannel.sendReceive('starknet_chainId').then((result) => { + // 3. This assertion runs after reconnection, proving the queue was processed. + expect(result).toBe(StarknetChainId.SN_SEPOLIA); + done(); // 4. Test is done when the queued request has been successfully processed. + }); + } + }); + }); - test('regular rpc endpoint', async () => { - const response = await webSocketChannel.sendReceive('starknet_chainId'); - expect(response).toBe(StarknetChainId.SN_SEPOLIA); + test('should queue subscribe requests when reconnecting and process them after', (done) => { + jest.setTimeout(30000); // Allow time for reconnect and a new block event + + webSocketChannel = new WebSocketChannel({ + nodeUrl: NODE_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let hasReconnected = false; + webSocketChannel.on('open', () => { + if (hasReconnected) { + // Reconnected. The promise from the queued subscribeNewHeads will resolve now. + } else { + // 1. First connection, now simulate a drop + hasReconnected = true; + webSocketChannel.websocket.close(); + + // 2. Immediately try to subscribe. The request should be queued. + webSocketChannel.subscribeNewHeads().then((sub) => { + // 3. This should only execute after reconnection. + expect(sub).toBeInstanceOf(Subscription); + expect(webSocketChannel.isConnected()).toBe(true); + + // 4. To prove it's a real subscription, wait for one event. + sub.on((data) => { + expect(data).toBeDefined(); + done(); + }); + }); + } + }); + }); + + test('should restore active subscriptions after an automatic reconnection', (done) => { + jest.setTimeout(30000); // Allow time for reconnect and new block + + webSocketChannel = new WebSocketChannel({ + nodeUrl: NODE_URL, + reconnectOptions: { retries: 3, delay: 100 }, + }); + + let connectionCount = 0; + + const eventHandler = (data: any) => { + // The handler is called. If this is after the reconnection (connectionCount > 1), + // it proves the subscription was successfully restored. + if (connectionCount > 1) { + expect(data).toBeDefined(); + done(); + } + }; + + webSocketChannel.on('open', async () => { + connectionCount += 1; + if (connectionCount === 1) { + // First connection: set up the subscription + const sub = await webSocketChannel.subscribeNewHeads(); + sub.on(eventHandler); + // Now, simulate a drop + webSocketChannel.websocket.close(); + } + // On the second 'open' event (connectionCount === 2), the test will implicitly + // be waiting for the eventHandler to be called, which will resolve the test. + }); + }); }); }); -describe('WebSocketChannel Buffering with Subscription object', () => { +describe('Unit Test: WebSocketChannel Buffering', () => { let webSocketChannel: WebSocketChannel; let sub: Subscription; @@ -167,7 +316,10 @@ describe('WebSocketChannel Buffering with Subscription object', () => { test('should buffer events and process upon handler attachment', async () => { // This test is for client-side buffering, so we don't need a real connection. - webSocketChannel = new WebSocketChannel({ nodeUrl: 'ws://dummy-url', autoReconnect: false }); + webSocketChannel = new WebSocketChannel({ + nodeUrl: 'ws://dummy-url', + autoReconnect: false, + }); // Mock unsubscribe to prevent network errors during cleanup in afterEach. jest.spyOn(webSocketChannel, 'unsubscribe').mockResolvedValue(true); @@ -211,7 +363,7 @@ describe('WebSocketChannel Buffering with Subscription object', () => { sub = new Subscription(webSocketChannel, 'starknet_subscribeNewHeads', {}, subId, 2); (webSocketChannel as any).activeSubscriptions.set(subId, sub); - const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {}); + const warnSpy = jest.spyOn(logger, 'warn').mockImplementation(() => {}); // Simulate 3 events to overflow the buffer. sub._handleEvent({ block_number: 1 }); @@ -233,145 +385,49 @@ describe('WebSocketChannel Buffering with Subscription object', () => { }); }); -describe('WebSocketChannel Auto-Reconnection', () => { - let webSocketChannel: WebSocketChannel; +describe('Unit Test: Subscription Class', () => { + let mockChannel: WebSocketChannel; + let subscription: Subscription; - afterEach(async () => { - // Ensure the channel is always disconnected after each test to prevent open handles. - if (webSocketChannel) { - webSocketChannel.disconnect(); - await webSocketChannel.waitForDisconnection(); - } - }); + beforeEach(() => { + // Create a mock WebSocketChannel. We don't need a real one for these tests. + mockChannel = new WebSocketChannel({ nodeUrl: 'ws://dummy-url' }); + // Mock the parts of the channel that the subscription interacts with. + mockChannel.unsubscribe = jest.fn().mockResolvedValue(true); + mockChannel.removeSubscription = jest.fn(); - test('should automatically reconnect on connection drop', (done) => { - // Set a very short reconnection delay for faster tests - webSocketChannel = new WebSocketChannel({ - nodeUrl: TEST_WS_URL, - reconnectOptions: { retries: 3, delay: 100 }, - }); - - let hasReconnected = false; - webSocketChannel.on('open', () => { - // This will be called once on initial connection, and a second time on reconnection. - if (hasReconnected) { - done(); // Test is successful if we get here - } else { - // This is the first connection, now we simulate the drop - hasReconnected = true; - webSocketChannel.websocket.close(); - } - }); + subscription = new Subscription(mockChannel, 'test_method', {}, 'sub_123', 100); }); - test('sendReceive should time out if no response is received', async () => { - webSocketChannel = new WebSocketChannel({ - nodeUrl: TEST_WS_URL, - requestTimeout: 100, // Set a short timeout for testing - }); - await webSocketChannel.waitForConnection(); + test('should throw an error if .on() is called more than once', () => { + const handler1 = jest.fn(); + const handler2 = jest.fn(); - // Spy on the 'send' method and prevent it from sending anything. - // This guarantees that we will never get a response and the timeout will be triggered. - const sendSpy = jest.spyOn(webSocketChannel.websocket, 'send').mockImplementation(() => {}); + subscription.on(handler1); // First call is fine. - // We expect this promise to reject with a timeout error. - await expect( - webSocketChannel.sendReceive('some_method_that_will_never_get_a_response') - ).rejects.toThrow('timed out after 100ms'); - - // Restore the original implementation for other tests - sendSpy.mockRestore(); + // Second call should throw. + expect(() => { + subscription.on(handler2); + }).toThrow('A handler is already attached to this subscription.'); }); - test('should queue sendReceive requests when reconnecting and process them after', (done) => { - webSocketChannel = new WebSocketChannel({ - nodeUrl: TEST_WS_URL, - reconnectOptions: { retries: 3, delay: 100 }, - }); - - let hasReconnected = false; - webSocketChannel.on('open', () => { - if (hasReconnected) { - // Reconnected. The promise from the queued sendReceive will resolve now. - } else { - // 1. First connection, now simulate a drop - hasReconnected = true; - webSocketChannel.websocket.close(); - - // 2. Immediately try to send a request. It should be queued. - webSocketChannel.sendReceive('starknet_chainId').then((result) => { - // 3. This assertion runs after reconnection, proving the queue was processed. - expect(result).toBe(StarknetChainId.SN_SEPOLIA); - done(); // 4. Test is done when the queued request has been successfully processed. - }); - } - }); - }); - - test('should queue subscribe requests when reconnecting and process them after', (done) => { - jest.setTimeout(30000); // Allow time for reconnect and a new block event - - webSocketChannel = new WebSocketChannel({ - nodeUrl: TEST_WS_URL, - reconnectOptions: { retries: 3, delay: 100 }, - }); - - let hasReconnected = false; - webSocketChannel.on('open', () => { - if (hasReconnected) { - // Reconnected. The promise from the queued subscribeNewHeads will resolve now. - } else { - // 1. First connection, now simulate a drop - hasReconnected = true; - webSocketChannel.websocket.close(); - - // 2. Immediately try to subscribe. The request should be queued. - webSocketChannel.subscribeNewHeads().then((sub) => { - // 3. This should only execute after reconnection. - expect(sub).toBeInstanceOf(Subscription); - expect(webSocketChannel.isConnected()).toBe(true); - - // 4. To prove it's a real subscription, wait for one event. - sub.on((data) => { - expect(data).toBeDefined(); - done(); - }); - }); - } - }); - }); - - test('should restore active subscriptions after an automatic reconnection', (done) => { - jest.setTimeout(30000); // Allow time for reconnect and new block + test('unsubscribe should be idempotent and only call the channel once', async () => { + // Call unsubscribe multiple times. + const result1 = await subscription.unsubscribe(); + const result2 = await subscription.unsubscribe(); + const result3 = await subscription.unsubscribe(); - webSocketChannel = new WebSocketChannel({ - nodeUrl: TEST_WS_URL, - reconnectOptions: { retries: 3, delay: 100 }, - }); + // All calls should report success. + expect(result1).toBe(true); + expect(result2).toBe(true); + expect(result3).toBe(true); - let connectionCount = 0; + // But the channel's unsubscribe method should only have been called once. + expect(mockChannel.unsubscribe).toHaveBeenCalledTimes(1); + expect(mockChannel.unsubscribe).toHaveBeenCalledWith('sub_123'); - const eventHandler = (data: any) => { - // The handler is called. If this is after the reconnection (connectionCount > 1), - // it proves the subscription was successfully restored. - if (connectionCount > 1) { - expect(data).toBeDefined(); - done(); - } - }; - - webSocketChannel.on('open', async () => { - connectionCount += 1; - if (connectionCount === 1) { - // First connection: set up the subscription - const sub = await webSocketChannel.subscribeNewHeads(); - sub.on(eventHandler); - // Now, simulate a drop - webSocketChannel.websocket.close(); - } - // On the second 'open' event (connectionCount === 2), the test will implicitly - // be waiting for the eventHandler to be called, which will resolve the test. - }); + // And the subscription should be removed from the channel once. + expect(mockChannel.removeSubscription).toHaveBeenCalledTimes(1); + expect(mockChannel.removeSubscription).toHaveBeenCalledWith('sub_123'); }); }); diff --git a/src/channel/ws/subscription.ts b/src/channel/ws/subscription.ts index 17be15102..c435dc486 100644 --- a/src/channel/ws/subscription.ts +++ b/src/channel/ws/subscription.ts @@ -54,6 +54,7 @@ export class Subscription { /** * The unique identifier for this subscription. + * @internal */ public id: SUBSCRIPTION_ID; @@ -108,7 +109,7 @@ export class Subscription { this.handler(data); } else { if (this.buffer.length >= this.maxBufferSize) { - const droppedEvent = this.buffer.shift(); // Drop the oldest event + const droppedEvent = this.buffer.shift(); // Drop the oldest event. logger.warn(`Subscription ${this.id}: Buffer full. Dropping oldest event:`, droppedEvent); } this.buffer.push(data); @@ -122,6 +123,7 @@ export class Subscription { * Subsequent events will be passed directly as they arrive. * * @param {(data: T) => void} handler - The function to call with event data. + * @throws {Error} If a handler is already attached to this subscription. */ public on(handler: (data: T) => void): void { if (this.handler) { @@ -131,7 +133,7 @@ export class Subscription { } this.handler = handler; - // Process buffer + // Process the buffer. while (this.buffer.length > 0) { const event = this.buffer.shift(); if (event) { @@ -153,7 +155,7 @@ export class Subscription { this._isClosed = true; this.channel.removeSubscription(this.id); this.events.emit('unsubscribe', undefined); - this.events.clear(); // Clean up all listeners + this.events.clear(); // Clean up all listeners. } return success; } diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index fe2c0a5ae..24933aa0f 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -15,6 +15,7 @@ import { EventEmitter } from '../../utils/eventEmitter'; import { TimeoutError, WebSocketNotConnectedError } from '../../utils/errors'; import WebSocket from '../../utils/connect/ws'; import { stringify } from '../../utils/json'; +import { isString, isObject } from '../../utils/typed'; import { bigNumberishArrayToHexadecimalStringArray, toHex } from '../../utils/num'; import { Block } from '../../utils/provider'; import { config } from '../../global/config'; @@ -46,7 +47,7 @@ export type WebSocketOptions = { * The URL of the WebSocket endpoint of the Starknet node. * @example 'ws://localhost:9545' */ - nodeUrl?: string; + nodeUrl: string; /** * This parameter can be used to provide a custom WebSocket implementation. * This is useful in environments where the global WebSocket object is not available (e.g., Node.js). @@ -56,7 +57,7 @@ export type WebSocketOptions = { * const channel = new WebSocketChannel({ nodeUrl: '...', websocket: WebSocket }); * ``` */ - websocket?: WebSocket; + websocket?: typeof WebSocket; /** * The maximum number of events to buffer per subscription when no handler is attached. * @default 1000 @@ -117,6 +118,9 @@ export class WebSocketChannel { */ public websocket: WebSocket; + // Store the WebSocket implementation class to allow for custom implementations. + private WsImplementation: typeof WebSocket; + // Map of active subscriptions, keyed by their ID. private activeSubscriptions: Map> = new Map(); @@ -154,8 +158,8 @@ export class WebSocketChannel { private errorListener = (ev: Event) => this.events.emit('error', ev); /** - * JSON RPC latest sent message id - * expecting receiving message to contain same id + * JSON RPC latest sent message ID. + * The receiving message is expected to contain the same ID. */ private sendId: number = 0; @@ -163,10 +167,8 @@ export class WebSocketChannel { * Creates an instance of WebSocketChannel. * @param {WebSocketOptions} options - The options for configuring the channel. */ - constructor(options: WebSocketOptions = {}) { - // provided existing websocket - const nodeUrl = options.nodeUrl || 'http://localhost:3000 '; - this.nodeUrl = options.websocket ? options.websocket.url : nodeUrl; + constructor(options: WebSocketOptions) { + this.nodeUrl = options.nodeUrl; this.maxBufferSize = options.maxBufferSize ?? 1000; this.autoReconnect = options.autoReconnect ?? true; this.reconnectOptions = { @@ -174,7 +176,9 @@ export class WebSocketChannel { delay: options.reconnectOptions?.delay ?? 2000, }; this.requestTimeout = options.requestTimeout ?? 60000; - this.websocket = options.websocket || config.get('websocket') || new WebSocket(nodeUrl); + + this.WsImplementation = options.websocket || config.get('websocket') || WebSocket; + this.websocket = new this.WsImplementation(this.nodeUrl); this.websocket.addEventListener('open', this.openListener); this.websocket.addEventListener('close', this.closeListener); @@ -183,9 +187,9 @@ export class WebSocketChannel { } private idResolver(id?: number) { - // unmanaged user set id + // An unmanaged, user-set ID. if (id) return id; - // managed id, intentional return old and than increment + // Managed ID, intentionally returned old and then incremented. // eslint-disable-next-line no-plusplus return this.sendId++; } @@ -202,7 +206,7 @@ export class WebSocketChannel { public send(method: string, params?: object, id?: number) { if (!this.isConnected()) { throw new WebSocketNotConnectedError( - 'WebSocketChannel.send() fail due to socket disconnected' + 'WebSocketChannel.send() failed due to socket being disconnected' ); } const usedId = this.idResolver(id); @@ -251,8 +255,8 @@ export class WebSocketChannel { } const messageHandler = (event: MessageEvent) => { - if (typeof event.data !== 'string') { - console.warn('WebSocket received non-string message data:', event.data); + if (!isString(event.data)) { + logger.warn('WebSocket received non-string message data:', event.data); return; } const message: JRPC.ResponseBody = JSON.parse(event.data); @@ -319,7 +323,7 @@ export class WebSocketChannel { * ``` */ public async waitForConnection(): Promise { - // Wait websocket to connect + // Wait for the websocket to connect if (this.websocket.readyState !== WebSocket.OPEN) { return new Promise((resolve, reject) => { if (!this.websocket) return; @@ -353,7 +357,7 @@ export class WebSocketChannel { * @returns {Promise} A Promise that resolves with the WebSocket's `readyState` or a `CloseEvent` when disconnected. */ public async waitForDisconnection(): Promise { - // Wait websocket to disconnect + // Wait for the websocket to disconnect if (this.websocket.readyState !== WebSocket.CLOSED) { return new Promise((resolve, reject) => { if (!this.websocket) return; @@ -410,7 +414,7 @@ export class WebSocketChannel { */ public reconnect() { this.userInitiatedClose = false; - this.websocket = new WebSocket(this.nodeUrl); + this.websocket = new this.WsImplementation(this.nodeUrl); this.websocket.addEventListener('open', this.openListener); this.websocket.addEventListener('close', this.closeListener); @@ -474,7 +478,7 @@ export class WebSocketChannel { this.reconnectAttempts = 0; await this._restoreSubscriptions(); this._processRequestQueue(); - // Manually trigger the onOpen listeners as the original event is gone. + // Manually trigger the onOpen listeners as the original 'open' event was consumed. this.events.emit('open', new Event('open')); }; @@ -508,17 +512,11 @@ export class WebSocketChannel { logger.error( `WebSocketChannel: Error parsing incoming message: ${event.data}, Error: ${error}` ); - return; // Stop processing this malformed message + return; // Stop processing this malformed message. } - // Check if it's a subscription event - if ( - message.method && - 'params' in message && - message.params && - typeof message.params === 'object' && - 'subscription_id' in message.params - ) { + // Check if it's a subscription event. + if (message.method && isObject(message.params) && 'subscription_id' in message.params) { const { result, subscription_id } = message.params as { result: any; subscription_id: SUBSCRIPTION_ID; @@ -534,7 +532,7 @@ export class WebSocketChannel { } } - // Call the general onMessage handler if provided by the user, for all messages. + // Call the general onMessage handler if provided by the user for all messages. this.events.emit('message', event); } @@ -623,7 +621,7 @@ export class WebSocketChannel { } /** - * internal method to remove subscription from active map + * Internal method to remove subscription from active map. * @internal */ public removeSubscription(id: SUBSCRIPTION_ID) { diff --git a/src/provider/index.ts b/src/provider/index.ts index c4dc08947..c43c2b2ed 100644 --- a/src/provider/index.ts +++ b/src/provider/index.ts @@ -1,7 +1,7 @@ import { RpcProvider } from './rpc'; export { RpcProvider as Provider } from './extensions/default'; // backward-compatibility -export { LibraryError, RpcError, starknetError, GatewayError } from '../utils/errors'; +export { LibraryError, RpcError } from '../utils/errors'; export * from './interface'; export * from './extensions/default'; diff --git a/src/utils/errors/index.ts b/src/utils/errors/index.ts index b73bdd08f..f8ee068db 100644 --- a/src/utils/errors/index.ts +++ b/src/utils/errors/index.ts @@ -3,8 +3,6 @@ import { RPC, RPC_ERROR, RPC_ERROR_SET } from '../../types'; import { stringify } from '../json'; import rpcErrors from './rpc'; -export * from './ws'; - // eslint-disable-next-line max-classes-per-file export function fixStack(target: Error, fn: Function = target.constructor) { const { captureStackTrace } = Error as any; @@ -79,30 +77,24 @@ export class RpcError extends LibraryE } } -// eslint-disable-next-line max-classes-per-file -export class starknetError extends Error { - name!: string; - - constructor(message?: string) { +/** + * Thrown when a WebSocket request does not receive a response within the configured timeout period. + * @property {string} name - The name of the error, always 'TimeoutError'. + */ +export class TimeoutError extends LibraryError { + constructor(message: string) { super(message); - // set error name as constructor name, make it not enumerable to keep native Error behavior - // see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/new.target#new.target_in_constructors - // see https://github.com/adriengibrat/ts-custom-error/issues/30 - Object.defineProperty(this, 'name', { - value: new.target.name, - enumerable: false, - configurable: true, - }); - // fix the extended error prototype chain - // because typescript __extends implementation can't - // see https://github.com/Microsoft/TypeScript-wiki/blob/master/Breaking-Changes.md#extending-built-ins-like-error-array-and-map-may-no-longer-work - fixProto(this, new.target.prototype); - // try to remove constructor from stack trace - fixStack(this); + this.name = 'TimeoutError'; } } /** - * @deprecated replaced by Cairo.getCompiledClass(...) + * Thrown when an operation is attempted on a WebSocket that is not connected. + * @property {string} name - The name of the error, always 'WebSocketNotConnectedError'. */ -export class GatewayError extends starknetError {} +export class WebSocketNotConnectedError extends LibraryError { + constructor(message: string) { + super(message); + this.name = 'WebSocketNotConnectedError'; + } +} diff --git a/src/utils/errors/ws.ts b/src/utils/errors/ws.ts deleted file mode 100644 index 3e7370342..000000000 --- a/src/utils/errors/ws.ts +++ /dev/null @@ -1,25 +0,0 @@ -/* eslint-disable max-classes-per-file */ - -/** - * Thrown when a WebSocket request does not receive a response within the configured timeout period. - * - * @property {string} name - The name of the error, always 'TimeoutError'. - */ -export class TimeoutError extends Error { - constructor(message: string) { - super(message); - this.name = 'TimeoutError'; - } -} - -/** - * Thrown when an operation is attempted on a WebSocket that is not connected. - * - * @property {string} name - The name of the error, always 'WebSocketNotConnectedError'. - */ -export class WebSocketNotConnectedError extends Error { - constructor(message: string) { - super(message); - this.name = 'WebSocketNotConnectedError'; - } -} diff --git a/www/docs/guides/websocket_channel.md b/www/docs/guides/websocket_channel.md index 56c5882ff..fd85b2abc 100644 --- a/www/docs/guides/websocket_channel.md +++ b/www/docs/guides/websocket_channel.md @@ -39,7 +39,7 @@ const channel = new WebSocketChannel({ nodeUrl: 'wss://your-starknet-node/rpc/v0_8', }); -// It's good practice to wait for the initial connection +// It's good practice to wait for the initial connection. await channel.waitForConnection(); ``` @@ -52,6 +52,8 @@ const channel = new WebSocketChannel({ nodeUrl: '...', websocket: WebSocket, // Provide the implementation class }); + +await channel.waitForConnection(); ``` ### Advanced Configuration @@ -98,7 +100,7 @@ await sub.unsubscribe(); If you `await` a subscription but don't immediately attach an `.on()` handler, the `Subscription` object will buffer incoming events. Once you attach a handler, all buffered events will be delivered in order before any new events are processed. This prevents event loss during asynchronous setup. -The buffer size is limited by `maxBufferSize` in the channel options. If the buffer is full, the oldest events are dropped. +The buffer size is limited by the `maxBufferSize` in the channel options. If the buffer is full, the oldest events are dropped. ## Automatic Reconnection and Queueing @@ -106,7 +108,7 @@ The channel is designed to be resilient. If the connection drops, it will automa - Any API calls (e.g., `sendReceive`, `subscribeNewHeads`) will be queued. - Once the connection is restored, the queue will be processed automatically. -- All previously active subscriptions will be **automatically re-subscribed**. You do not need to manually handle this. +- All previously active subscriptions will be **automatically re-subscribed**. The original `Subscription` objects you hold will continue to work without any need for manual intervention. ## Error Handling From 0af59005dbda825f6e8398edd6d205c94efc5896 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 16 Jun 2025 14:35:08 +0200 Subject: [PATCH 13/16] chore: debug & details --- __tests__/WebSocketChannel.test.ts | 4 ++-- src/channel/ws/ws_0_8.ts | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/__tests__/WebSocketChannel.test.ts b/__tests__/WebSocketChannel.test.ts index a4a9a022b..10eaaf30b 100644 --- a/__tests__/WebSocketChannel.test.ts +++ b/__tests__/WebSocketChannel.test.ts @@ -8,7 +8,7 @@ const describeIfWs = TEST_WS_URL ? describe : describe.skip; const NODE_URL = TEST_WS_URL!; describeIfWs('E2E WebSocket Tests', () => { - describe('websocket specific endpoints - pathfinder test', () => { + describe('websocket specific endpoints', () => { // account provider const provider = new Provider(getTestProvider()); const account = getTestAccount(provider); @@ -134,7 +134,7 @@ describeIfWs('E2E WebSocket Tests', () => { }); }); - describe('websocket regular endpoints - pathfinder test', () => { + describe('websocket regular endpoints', () => { let webSocketChannel: WebSocketChannel; beforeAll(async () => { diff --git a/src/channel/ws/ws_0_8.ts b/src/channel/ws/ws_0_8.ts index 24933aa0f..7e9dee8b7 100644 --- a/src/channel/ws/ws_0_8.ts +++ b/src/channel/ws/ws_0_8.ts @@ -532,6 +532,8 @@ export class WebSocketChannel { } } + logger.debug('onMessageProxy:', event.data); + // Call the general onMessage handler if provided by the user for all messages. this.events.emit('message', event); } From 787f2a04640ae6f90b9fc6090fbce935a2b164eb Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 16 Jun 2025 16:42:32 +0200 Subject: [PATCH 14/16] test: add .env, fix setup data log when run from Jest extension --- example.env | 13 +++++++++++++ jest.config.js | 12 ++++++++++++ 2 files changed, 25 insertions(+) create mode 100644 example.env create mode 100644 jest.config.js diff --git a/example.env b/example.env new file mode 100644 index 000000000..f335c42d9 --- /dev/null +++ b/example.env @@ -0,0 +1,13 @@ +# Test Setup 1. +# TEST_ACCOUNT_ADDRESS= +# TEST_ACCOUNT_PRIVATE_KEY= +# TEST_RPC_URL= +# TEST_WS_URL= +# DEBUG=true + + +# Test Setup 2. +TEST_ACCOUNT_ADDRESS= +TEST_ACCOUNT_PRIVATE_KEY= +TEST_RPC_URL= +TEST_WS_URL= \ No newline at end of file diff --git a/jest.config.js b/jest.config.js new file mode 100644 index 000000000..7dfe3074d --- /dev/null +++ b/jest.config.js @@ -0,0 +1,12 @@ +module.exports = { + verbose: true, + modulePathIgnorePatterns: ['dist'], + setupFilesAfterEnv: ['./__tests__/config/jest.setup.ts'], + snapshotFormat: { + escapeString: true, + printBasicPrototype: true, + }, + testMatch: ['**/__tests__/**/(*.)+(spec|test).[jt]s?(x)'], + globalSetup: './__tests__/config/jestGlobalSetup.ts', + sandboxInjectedGlobals: ['Math'], +}; From e98d3b830ceb61ef697c2dcedf8d4ca093a10f4b Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Mon, 16 Jun 2025 16:47:53 +0200 Subject: [PATCH 15/16] test: add .env, fix setup data log when run from Jest extension 2 --- __tests__/config/fixtures.ts | 4 ++- __tests__/config/helpers/strategyResolver.ts | 29 ++++++++++---------- __tests__/config/jestGlobalSetup.ts | 1 + package-lock.json | 14 ++++++++++ package.json | 23 +++------------- 5 files changed, 37 insertions(+), 34 deletions(-) diff --git a/__tests__/config/fixtures.ts b/__tests__/config/fixtures.ts index 430e97605..7c07dab72 100644 --- a/__tests__/config/fixtures.ts +++ b/__tests__/config/fixtures.ts @@ -81,7 +81,9 @@ const compiledContracts = { }; export const contracts = mapContractSets(compiledContracts); -config.set('logLevel', 'DEBUG'); +if (process.env.DEBUG) { + config.set('logLevel', 'DEBUG'); +} export function getTestProvider( isProvider?: true, diff --git a/__tests__/config/helpers/strategyResolver.ts b/__tests__/config/helpers/strategyResolver.ts index ededf6c9e..d2bfc3772 100644 --- a/__tests__/config/helpers/strategyResolver.ts +++ b/__tests__/config/helpers/strategyResolver.ts @@ -89,7 +89,6 @@ class StrategyResolver { TEST_RPC_URL: process.env.TEST_RPC_URL, TEST_WS_URL: process.env.TEST_WS_URL, TX_VERSION: process.env.TX_VERSION, - SPEC_VERSION: process.env.SPEC_VERSION, }); console.table({ @@ -97,6 +96,7 @@ class StrategyResolver { IS_RPC: process.env.IS_RPC, IS_TESTNET: process.env.IS_TESTNET, 'Detected Spec Version': process.env.RPC_SPEC_VERSION, + DEBUG: process.env.DEBUG, }); console.log('Global Test Environment is Ready'); @@ -131,25 +131,26 @@ class StrategyResolver { console.log('Global Test Setup Started'); this.verifyAccountData(); - if (this.hasAllAccountEnvs) { - await this.useProvidedSetup(); - return; - } + if (!this.hasAllAccountEnvs) { + // 2. Try to detect devnet setup + console.log('Basic test parameters are missing, Auto Setup Started'); - // 2. Try to detect devnet setup - console.log('Basic test parameters are missing, Auto Setup Started'); + await this.detectDevnet(); + await accountResolver.execute(this.isDevnet); - await this.detectDevnet(); - await this.resolveRpc(); - await accountResolver.execute(this.isDevnet); - - this.verifyAccountData(true); - if (!this.hasAllAccountEnvs) console.error('Test Setup Environment is NOT Ready'); + this.verifyAccountData(true); + if (!this.hasAllAccountEnvs) console.error('Test Setup Environment is NOT Ready'); + } + await this.resolveRpc(); this.defineTestTransactionVersion(); await this.getNodeSpecVersion(); - this.logConfigInfo(); + if (this.hasAllAccountEnvs) { + await this.useProvidedSetup(); + } else { + this.logConfigInfo(); + } } } diff --git a/__tests__/config/jestGlobalSetup.ts b/__tests__/config/jestGlobalSetup.ts index c804681c7..aa30b86b1 100644 --- a/__tests__/config/jestGlobalSetup.ts +++ b/__tests__/config/jestGlobalSetup.ts @@ -5,6 +5,7 @@ * ref: order of execution jestGlobalSetup.ts -> jest.setup.ts -> fixtures.ts */ +import 'dotenv/config'; import strategyResolver from './helpers/strategyResolver'; /** diff --git a/package-lock.json b/package-lock.json index 938d790c4..438ae35e7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -40,6 +40,7 @@ "@typescript-eslint/parser": "^7.4.0", "ajv": "^8.12.0", "ajv-keywords": "^5.1.0", + "dotenv": "^16.5.0", "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -7311,6 +7312,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.5.0", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.5.0.tgz", + "integrity": "sha512-m/C+AwOAr9/W1UOIZUo232ejMNnJAJtYQjUbHoNTBNTJSvqzzDh7vnrei3o3r3m9blf6ZoDkvcw0VmozNRFJxg==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index f6a03f07e..b59212126 100644 --- a/package.json +++ b/package.json @@ -33,8 +33,8 @@ "pretest": "npm run lint && npm run ts:check", "test": "jest -i --detectOpenHandles", "test:coverage": "jest -i --coverage", - "posttest": "npm run format -- --log-level warn", "test:watch": "jest --watch", + "posttest": "npm run format -- --log-level warn", "docs": "cd www && npm run start", "docs:build": "cd www && GIT_REVISION_OVERRIDE=${npm_config_git_revision_override} npm run build", "docs:build:version": "v=$(npm run info:version -s) && npm run docs:build --git-revision-override=${npm_config_git_revision_override=v$v}", @@ -74,6 +74,7 @@ "@typescript-eslint/parser": "^7.4.0", "ajv": "^8.12.0", "ajv-keywords": "^5.1.0", + "dotenv": "^16.5.0", "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -102,11 +103,11 @@ "@noble/hashes": "1.6.0", "@scure/base": "1.2.1", "@scure/starknet": "1.1.0", + "@starknet-io/starknet-types-07": "npm:@starknet-io/types-js@~0.7.10", + "@starknet-io/starknet-types-08": "npm:@starknet-io/types-js@~0.8.4", "abi-wan-kanabi": "2.2.4", "lossless-json": "^4.0.1", "pako": "^2.0.4", - "@starknet-io/starknet-types-07": "npm:@starknet-io/types-js@~0.7.10", - "@starknet-io/starknet-types-08": "npm:@starknet-io/types-js@~0.8.4", "ts-mixer": "^6.0.3" }, "engines": { @@ -116,22 +117,6 @@ "*.ts": "eslint --cache --fix", "*.{ts,js,md,yml,json}": "prettier --write" }, - "jest": { - "snapshotFormat": { - "escapeString": true, - "printBasicPrototype": true - }, - "testMatch": [ - "**/__tests__/**/(*.)+(spec|test).[jt]s?(x)" - ], - "setupFilesAfterEnv": [ - "./__tests__/config/jest.setup.ts" - ], - "globalSetup": "./__tests__/config/jestGlobalSetup.ts", - "sandboxInjectedGlobals": [ - "Math" - ] - }, "importSort": { ".js, .jsx, .ts, .tsx": { "style": "module", From 17ce7b189e872c183fc4e3e7ac12eb1572df99e1 Mon Sep 17 00:00:00 2001 From: Toni Tabak Date: Tue, 17 Jun 2025 09:35:23 +0200 Subject: [PATCH 16/16] test: multy-setup test option --- package-lock.json | 27 +++++++++++++++++++++++++++ package.json | 6 +++++- 2 files changed, 32 insertions(+), 1 deletion(-) diff --git a/package-lock.json b/package-lock.json index 438ae35e7..f8441405c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -41,6 +41,7 @@ "ajv": "^8.12.0", "ajv-keywords": "^5.1.0", "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0", @@ -7325,6 +7326,32 @@ "url": "https://dotenvx.com" } }, + "node_modules/dotenv-cli": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/dotenv-cli/-/dotenv-cli-8.0.0.tgz", + "integrity": "sha512-aLqYbK7xKOiTMIRf1lDPbI+Y+Ip/wo5k3eyp6ePysVaSqbyxjyK3dK35BTxG+rmd7djf5q2UPs4noPNH+cj0Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "cross-spawn": "^7.0.6", + "dotenv": "^16.3.0", + "dotenv-expand": "^10.0.0", + "minimist": "^1.2.6" + }, + "bin": { + "dotenv": "cli.js" + } + }, + "node_modules/dotenv-expand": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/dotenv-expand/-/dotenv-expand-10.0.0.tgz", + "integrity": "sha512-GopVGCpVS1UKH75VKHGuQFqS1Gusej0z4FyQkPdwjil2gNIv+LNsqBlboOzpJFZKVT95GkCyWJbBSdFEFUWI2A==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + } + }, "node_modules/dunder-proto": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", diff --git a/package.json b/package.json index b59212126..bd17d981d 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,10 @@ "lint": "eslint . --cache --fix --ext .ts", "ts:check": "tsc --noEmit --resolveJsonModule --project tsconfig.eslint.json", "ts:coverage": "type-coverage --at-least 95", - "ts:coverage:report": "typescript-coverage-report" + "ts:coverage:report": "typescript-coverage-report", + "test:acc1": "dotenv -e .env.account1 -- npm test", + "test:acc2": "dotenv -e .env.account2 -- npm test", + "test:all-accounts": "npm run test:acc1 && npm run test:acc2" }, "keywords": [ "starknet", @@ -75,6 +78,7 @@ "ajv": "^8.12.0", "ajv-keywords": "^5.1.0", "dotenv": "^16.5.0", + "dotenv-cli": "^8.0.0", "eslint": "^8.56.0", "eslint-config-airbnb-base": "^15.0.0", "eslint-config-airbnb-typescript": "^18.0.0",