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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
550 changes: 395 additions & 155 deletions __tests__/WebSocketChannel.test.ts

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion __tests__/config/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
29 changes: 15 additions & 14 deletions __tests__/config/helpers/strategyResolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -89,14 +89,14 @@ 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({
IS_DEVNET: process.env.IS_DEVNET,
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');
Expand Down Expand Up @@ -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();
}
}
}

Expand Down
1 change: 1 addition & 0 deletions __tests__/config/jestGlobalSetup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
* ref: order of execution jestGlobalSetup.ts -> jest.setup.ts -> fixtures.ts
*/

import 'dotenv/config';
import strategyResolver from './helpers/strategyResolver';

/**
Expand Down
13 changes: 13 additions & 0 deletions example.env
Original file line number Diff line number Diff line change
@@ -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=
12 changes: 12 additions & 0 deletions jest.config.js
Original file line number Diff line number Diff line change
@@ -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'],
};
41 changes: 41 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

31 changes: 10 additions & 21 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -31,10 +31,10 @@
"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",
"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}",
Expand All @@ -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",
Expand Down Expand Up @@ -74,6 +77,8 @@
"@typescript-eslint/parser": "^7.4.0",
"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",
Expand Down Expand Up @@ -102,11 +107,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": {
Expand All @@ -116,22 +121,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",
Expand Down
4 changes: 3 additions & 1 deletion src/channel/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,6 @@ 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 { WebSocketChannel, WebSocketOptions } from './ws/ws_0_8';
export { Subscription } from './ws/subscription';
export { TimeoutError, WebSocketNotConnectedError } from '../utils/errors';
162 changes: 162 additions & 0 deletions src/channel/ws/subscription.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
/* 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';
import { EventEmitter } from '../../utils/eventEmitter';

type SubscriptionEvents<T> = {
event: T;
error: Error;
unsubscribe: void;
};

/**
* 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<T = any> {
/**
* The containing `WebSocketChannel` instance.
* @internal
*/
public 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.
* @internal
*/
public id: SUBSCRIPTION_ID;

private events = new EventEmitter<SubscriptionEvents<T>>();

private buffer: T[] = [];

private maxBufferSize: number;

private handler: ((data: T) => void) | null = null;

private _isClosed = false;

/**
* @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;
}

/**
* Indicates if the subscription has been closed.
* @returns {boolean} `true` if unsubscribed, `false` otherwise.
*/
public get isClosed(): boolean {
return this._isClosed;
}

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

/**
* 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.
* @throws {Error} If a handler is already attached to this subscription.
*/
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.handler = handler;

// Process the buffer.
while (this.buffer.length > 0) {
const event = this.buffer.shift();
if (event) {
this.handler(event);
}
}
}

/**
* Sends an unsubscribe request to the node and cleans up local resources.
* @returns {Promise<boolean>} A Promise that resolves to `true` if the unsubscription was successful.
*/
public async unsubscribe(): Promise<boolean> {
if (this._isClosed) {
return true; // Already unsubscribed, treat as success.
}
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;
}
}
Loading