Skip to content

Commit

Permalink
Implement EventEmitter compatible with browsers (#6398)
Browse files Browse the repository at this point in the history
* implement `EventEmitter` compatible with browsers

* add node and jsdom unit tests for EventEmitter 

* add Cypress configuration to web3-utils

* test EventEmitter in the browser with Cypress

* update CHANGELOG.md

---------

Co-authored-by: Junaid <86780488+jdevcs@users.noreply.github.com>
  • Loading branch information
Muhammad-Altabba and jdevcs authored Oct 9, 2023
1 parent ae98628 commit 4879326
Show file tree
Hide file tree
Showing 16 changed files with 887 additions and 13 deletions.
5 changes: 5 additions & 0 deletions packages/web3-core/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,3 +185,8 @@ Documentation:

- defaultTransactionType is now type 0x2 instead of 0x0 (#6282)
- Allows formatter to parse large base fee (#6456)
- The package now uses `EventEmitter` from `web3-utils` that works in node envrioment as well as in the browser. (#6398)

### Fixed

- Fix the issue: "Uncaught TypeError: Class extends value undefined is not a constructor or null #6371". (#6398)
2 changes: 1 addition & 1 deletion packages/web3-core/src/web3_event_emitter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

import { EventEmitter } from 'events';
import { EventEmitter } from 'web3-utils';

export type Web3EventMap = Record<string, unknown>;
export type Web3EventKey<T extends Web3EventMap> = string & keyof T;
Expand Down
3 changes: 1 addition & 2 deletions packages/web3-eth-accounts/src/common/common.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,8 @@ You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/
import pkg from 'crc-32';
import { EventEmitter } from 'events';
import { EventEmitter, bytesToHex, hexToBytes, uint8ArrayConcat } from 'web3-utils';
import type { Numbers } from 'web3-types';
import { bytesToHex, hexToBytes, uint8ArrayConcat } from 'web3-utils';
import { TypeOutput } from './types.js';
import { intToUint8Array, toType, parseGethGenesis } from './utils.js';
import goerli from './chains/goerli.js';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

import { EventEmitter } from 'events';
import { EventEmitter } from 'web3-utils';

export default class WebSocket extends EventEmitter {
public readyState: number;
Expand Down
2 changes: 2 additions & 0 deletions packages/web3-utils/.eslintignore
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,5 @@ dist
lib
jest.config.js
.eslintrc.js
cypress
cypress.config.js
6 changes: 5 additions & 1 deletion packages/web3-utils/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -158,4 +158,8 @@ Documentation:

- `soliditySha3()` with BigInt support

## [Unreleased]
## [Unreleased]

### Added

- As a replacment of the node EventEmitter, a custom `EventEmitter` has been implemented and exported. (#6398)
1 change: 1 addition & 0 deletions packages/web3-utils/cypress
1 change: 1 addition & 0 deletions packages/web3-utils/cypress.config.js
6 changes: 5 additions & 1 deletion packages/web3-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,10 @@
"test:ci": "jest --coverage=true --coverage-reporters=json --verbose",
"test:watch": "npm test -- --watch",
"test:unit": "jest --config=./test/unit/jest.config.js",
"test:integration": "jest --config=./test/integration/jest.config.js --passWithNoTests"
"test:integration": "jest --config=./test/integration/jest.config.js --passWithNoTests",
"test:e2e:electron": "npx cypress run --headless --browser electron",
"test:e2e:chrome": "npx cypress run --headless --browser chrome",
"test:e2e:firefox": "npx cypress run --headless --browser firefox"
},
"devDependencies": {
"@humeris/espresso-shot": "^4.0.0",
Expand All @@ -52,6 +55,7 @@
"eslint-config-prettier": "^8.5.0",
"eslint-plugin-import": "^2.26.0",
"jest": "^28.1.3",
"jest-environment-jsdom": "^29.7.0",
"jest-extended": "^3.0.1",
"js-sha3": "^0.8.0",
"prettier": "^2.7.1",
Expand Down
128 changes: 128 additions & 0 deletions packages/web3-utils/src/event_emitter.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
/*
This file is part of web3.js.
web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
web3.js is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/
/* eslint-disable max-classes-per-file */

import { EventEmitter as EventEmitterAtNode } from 'events';

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type Callback = (params: any) => void | Promise<void>;

type EventTargetCallback = (params: CustomEvent) => void;

const wrapFunction =
(fn: Callback): EventTargetCallback =>
(params: CustomEvent) =>
fn(params.detail);

/**
* This class copy the behavior of Node.js EventEmitter class.
* It is used to provide the same interface for the browser environment.
*/
class EventEmitterAtBrowser extends EventTarget {
private _listeners: Record<string, [key: Callback, value: EventTargetCallback][]> = {};
private maxListeners = Number.MAX_SAFE_INTEGER;

public on(eventName: string, fn: Callback) {
this.addEventListener(eventName, fn);
return this;
}

public once(eventName: string, fn: Callback) {
const onceCallback = async (params: Callback) => {
this.off(eventName, onceCallback);
await fn(params);
};
return this.on(eventName, onceCallback);
}

public off(eventName: string, fn: Callback) {
this.removeEventListener(eventName, fn);
return this;
}

public emit(eventName: string, params: unknown) {
const event = new CustomEvent(eventName, { detail: params });
return super.dispatchEvent(event);
}

public listenerCount(eventName: string): number {
const eventListeners = this._listeners[eventName];
return eventListeners ? eventListeners.length : 0;
}

public listeners(eventName: string): Callback[] {
return this._listeners[eventName].map(value => value[0]) || [];
}

public eventNames(): string[] {
return Object.keys(this._listeners);
}

public removeAllListeners() {
Object.keys(this._listeners).forEach(event => {
this._listeners[event].forEach(
(listener: [key: Callback, value: EventTargetCallback]) => {
super.removeEventListener(event, listener[1] as EventListener);
},
);
});

this._listeners = {};
return this;
}

public setMaxListeners(maxListeners: number) {
this.maxListeners = maxListeners;
return this;
}

public getMaxListeners(): number {
return this.maxListeners;
}

public addEventListener(eventName: string, fn: Callback) {
const wrappedFn = wrapFunction(fn);
super.addEventListener(eventName, wrappedFn as EventListener);
if (!this._listeners[eventName]) {
this._listeners[eventName] = [];
}
this._listeners[eventName].push([fn, wrappedFn]);
}

public removeEventListener(eventName: string, fn: Callback) {
const eventListeners = this._listeners[eventName];
if (eventListeners) {
const index = eventListeners.findIndex(item => item[0] === fn);
if (index !== -1) {
super.removeEventListener(eventName, eventListeners[index][1] as EventListener);
eventListeners.splice(index, 1);
}
}
}
}

// eslint-disable-next-line import/no-mutable-exports
let EventEmitterType: typeof EventEmitterAtNode;
// Check if the code is running in a Node.js environment
if (typeof window === 'undefined') {
EventEmitterType = EventEmitterAtNode;
} else {
// Fallback for the browser environment
EventEmitterType = EventEmitterAtBrowser as unknown as typeof EventEmitterAtNode;
}

export class EventEmitter extends EventEmitterType {}
1 change: 1 addition & 0 deletions packages/web3-utils/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

export * from './converters.js';
export * from './event_emitter.js';
export * from './validation.js';
export * from './formatter.js';
export * from './hash.js';
Expand Down
124 changes: 124 additions & 0 deletions packages/web3-utils/test/integration/event_emitter.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
/*
This file is part of web3.js.
web3.js is free software: you can redistribute it and/or modify
it under the terms of the GNU Lesser General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
web3.js is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Lesser General Public License for more details.
You should have received a copy of the GNU Lesser General Public License
along with web3.js. If not, see <http://www.gnu.org/licenses/>.
*/

// this file contains the unit test for the event emitter in the DOM environment
// it is executed in the jsdom environment (see "@jest-environment jsdom" in the top comment of this file)

// ignore the following rule to allow keeping `@jest-environment jsdom` on top:
// eslint-disable-next-line header/header
import { EventEmitter } from '../../src/event_emitter';

describe('EventEmitter in the browser with Cypress', () => {
let emitter: EventEmitter;

beforeEach(() => {
emitter = new EventEmitter();
});

describe('on', () => {
it('should add a listener for the specified event', () => {
const callback = jest.fn();
emitter.on('test', callback);
emitter.emit('test', 'hello');
expect(callback).toHaveBeenCalledWith('hello');
});
});

describe('once', () => {
it('should add a listener for the specified event that is only called once', () => {
const callback = jest.fn();
emitter.once('test', callback);
emitter.emit('test', 'hello');
emitter.emit('test', 'world');
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('hello');
});
});

describe('off', () => {
it('should remove a listener for the specified event', () => {
const callback = jest.fn();
emitter.on('test', callback);
emitter.off('test', callback);
emitter.emit('test', 'hello');
expect(callback).not.toHaveBeenCalled();
});
});

describe('emit', () => {
it('should call all listeners for the specified event', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
emitter.on('test', callback1);
emitter.on('test', callback2);
emitter.emit('test', 'hello');
expect(callback1).toHaveBeenCalledWith('hello');
expect(callback2).toHaveBeenCalledWith('hello');
});
});

describe('listenerCount', () => {
it('should return the number of listeners for the specified event', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
emitter.on('test', callback1);
emitter.on('test', callback2);
expect(emitter.listenerCount('test')).toBe(2);
});
});

describe('listeners', () => {
it('should return an array of listeners for the specified event', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
emitter.on('test', callback1);
emitter.on('test', callback2);
expect(emitter.listeners('test')).toEqual([callback1, callback2]);
});
});

describe('eventNames', () => {
it('should return an array of event names that have listeners', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
emitter.on('test1', callback1);
emitter.on('test2', callback2);
expect(emitter.eventNames()).toEqual(['test1', 'test2']);
});
});

describe('removeAllListeners', () => {
it('should remove all listeners for all events', () => {
const callback1 = jest.fn();
const callback2 = jest.fn();
emitter.on('test1', callback1);
emitter.on('test2', callback2);
emitter.removeAllListeners();
emitter.emit('test1', 'hello');
emitter.emit('test2', 'world');
expect(callback1).not.toHaveBeenCalled();
expect(callback2).not.toHaveBeenCalled();
});
});

describe('setMaxListeners', () => {
it('should set the maximum number of listeners for an event', () => {
emitter.setMaxListeners(2);
expect(emitter.getMaxListeners()).toBe(2);
});
});
});
Loading

0 comments on commit 4879326

Please sign in to comment.