diff --git a/packages/polling-controller/CHANGELOG.md b/packages/polling-controller/CHANGELOG.md new file mode 100644 index 0000000000..27eb830b8c --- /dev/null +++ b/packages/polling-controller/CHANGELOG.md @@ -0,0 +1,9 @@ +# Changelog +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/polling-controller/LICENSE b/packages/polling-controller/LICENSE new file mode 100644 index 0000000000..ddfbecf902 --- /dev/null +++ b/packages/polling-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2018 MetaMask + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE diff --git a/packages/polling-controller/README.md b/packages/polling-controller/README.md new file mode 100644 index 0000000000..bade0a3e03 --- /dev/null +++ b/packages/polling-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/polling-controller` + +PollingController is used as the base for all controllers that need to poll for updates based on `networkClientId`. + +## Installation + +`yarn add @metamask/polling-controller` + +or + +`npm install @metamask/polling-controller` + +## Contributing + +This package is part of a monorepo. Instructions for contributing can be found in the [monorepo README](https://github.com/MetaMask/core#readme). diff --git a/packages/polling-controller/jest.config.js b/packages/polling-controller/jest.config.js new file mode 100644 index 0000000000..17db4cd31b --- /dev/null +++ b/packages/polling-controller/jest.config.js @@ -0,0 +1,26 @@ +/* + * For a detailed explanation regarding each configuration property and type check, visit: + * https://jestjs.io/docs/configuration + */ + +const merge = require('deepmerge'); +const path = require('path'); + +const baseConfig = require('../../jest.config.packages'); + +const displayName = path.basename(__dirname); + +module.exports = merge(baseConfig, { + // The display name when running multiple projects + displayName, + + // An object that configures minimum threshold enforcement for coverage results + coverageThreshold: { + global: { + branches: 65.31, + functions: 76.59, + lines: 75.83, + statements: 75.91, + }, + }, +}); diff --git a/packages/polling-controller/package.json b/packages/polling-controller/package.json new file mode 100644 index 0000000000..40eb086809 --- /dev/null +++ b/packages/polling-controller/package.json @@ -0,0 +1,58 @@ +{ + "name": "@metamask/polling-controller", + "version": "0.0.0", + "description": "Polling Controller is the base for controllers that polling by networkClientId", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/polling-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "files": [ + "dist/" + ], + "scripts": { + "build:docs": "typedoc", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/polling-controller", + "publish:preview": "yarn npm publish --tag preview", + "test": "jest", + "test:watch": "jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^3.2.1", + "@metamask/controller-utils": "^5.0.0", + "@metamask/network-controller": "^13.0.0", + "@metamask/utils": "^6.2.0", + "@types/uuid": "^8.3.0", + "uuid": "^8.3.2" + }, + "devDependencies": { + "@metamask/auto-changelog": "^3.1.0", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.23.15", + "typedoc-plugin-missing-exports": "^0.23.0", + "typescript": "~4.6.3" + }, + "peerDependencies": { + "@metamask/network-controller": "^13.0.0" + }, + "engines": { + "node": ">=16.0.0" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} diff --git a/packages/polling-controller/src/PollingController.test.ts b/packages/polling-controller/src/PollingController.test.ts new file mode 100644 index 0000000000..26c583d2c6 --- /dev/null +++ b/packages/polling-controller/src/PollingController.test.ts @@ -0,0 +1,308 @@ +import { ControllerMessenger } from '@metamask/base-controller'; + +import type { PollingCompleteType } from './PollingController'; +import PollingController from './PollingController'; + +const TICK_TIME = 1000; + +const createExecutePollMock = () => { + const executePollMock = jest.fn().mockImplementation(async () => { + return true; + }); + return executePollMock; +}; + +describe('PollingController', () => { + describe('start', () => { + it('should start polling if not polling', () => { + jest.useFakeTimers(); + + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + controller.stopAll(); + expect(controller.executePoll).toHaveBeenCalledTimes(1); + }); + }); + describe('stop', () => { + it('should stop polling when called with a valid polling that was the only active pollingToken for a given networkClient', () => { + jest.useFakeTimers(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + const pollingToken = controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + controller.stop(pollingToken); + jest.advanceTimersByTime(TICK_TIME); + expect(controller.executePoll).toHaveBeenCalledTimes(1); + controller.stopAll(); + }); + it('should not stop polling if called with one of multiple active polling tokens for a given networkClient', async () => { + jest.useFakeTimers(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + const pollingToken1 = controller.start('mainnet'); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + controller.stop(pollingToken1); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).toHaveBeenCalledTimes(2); + controller.stopAll(); + }); + it('should error if no pollingToken is passed', () => { + jest.useFakeTimers(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + expect(() => { + controller.stop(undefined as unknown as any); + }).toThrow('pollingToken required'); + controller.stopAll(); + }); + it('should error if no matching pollingToken is found', () => { + jest.useFakeTimers(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + expect(() => { + controller.stop('potato'); + }).toThrow('pollingToken not found'); + controller.stopAll(); + }); + }); + describe('poll', () => { + it('should call executePoll if polling', async () => { + jest.useFakeTimers(); + + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).toHaveBeenCalledTimes(2); + }); + it('should continue calling executePoll when start is called again with the same networkClientId', async () => { + jest.useFakeTimers(); + + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).toHaveBeenCalledTimes(2); + controller.stopAll(); + }); + it('should publish "pollingComplete" when stop is called', async () => { + jest.useFakeTimers(); + const pollingComplete: any = jest.fn(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const name = 'PollingController'; + + const mockMessenger = new ControllerMessenger< + any, + PollingCompleteType + >(); + + mockMessenger.subscribe(`${name}:pollingComplete`, pollingComplete); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name, + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + const pollingToken = controller.start('mainnet'); + controller.stop(pollingToken); + expect(pollingComplete).toHaveBeenCalledTimes(1); + }); + it('should poll at the interval length passed via the constructor', async () => { + jest.useFakeTimers(); + + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME * 3, + }); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).not.toHaveBeenCalled(); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).not.toHaveBeenCalled(); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll).toHaveBeenCalledTimes(1); + jest.advanceTimersByTime(TICK_TIME * 3); + await Promise.resolve(); + expect(controller.executePoll).toHaveBeenCalledTimes(2); + }); + }); + describe('multiple networkClientIds', () => { + it('should poll for each networkClientId', async () => { + jest.useFakeTimers(); + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME, + }); + controller.start('mainnet'); + controller.start('rinkeby'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([ + ['mainnet'], + ['rinkeby'], + ]); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([ + ['mainnet'], + ['rinkeby'], + ['mainnet'], + ['rinkeby'], + ]); + controller.stopAll(); + }); + + it('should poll multiple networkClientIds at the interval length passed via the constructor', async () => { + jest.useFakeTimers(); + + class MyGasFeeController extends PollingController { + executePoll = createExecutePollMock(); + } + const mockMessenger = new ControllerMessenger(); + + const controller = new MyGasFeeController({ + messenger: mockMessenger, + metadata: {}, + name: 'PollingController', + state: { foo: 'bar' }, + pollingIntervalLength: TICK_TIME * 2, + }); + controller.start('mainnet'); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + controller.start('sepolia'); + expect(controller.executePoll.mock.calls).toMatchObject([]); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([['mainnet']]); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([ + ['mainnet'], + ['sepolia'], + ]); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([ + ['mainnet'], + ['sepolia'], + ['mainnet'], + ]); + jest.advanceTimersByTime(TICK_TIME); + await Promise.resolve(); + expect(controller.executePoll.mock.calls).toMatchObject([ + ['mainnet'], + ['sepolia'], + ['mainnet'], + ['sepolia'], + ]); + }); + }); +}); diff --git a/packages/polling-controller/src/PollingController.ts b/packages/polling-controller/src/PollingController.ts new file mode 100644 index 0000000000..a9595cc4e8 --- /dev/null +++ b/packages/polling-controller/src/PollingController.ts @@ -0,0 +1,150 @@ +import { BaseControllerV2 } from '@metamask/base-controller'; +import type { + RestrictedControllerMessenger, + StateMetadata, +} from '@metamask/base-controller'; +import type { NetworkClientId } from '@metamask/network-controller'; +import type { Json } from '@metamask/utils'; +import { v4 as random } from 'uuid'; + +export type PollingCompleteType = { + type: `${N}:pollingComplete`; + payload: [string]; +}; + +/** + * PollingController is an abstract class that implements the polling + * functionality for a controller. It is meant to be extended by a controller + * that needs to poll for data by networkClientId. + * + */ +export default abstract class PollingController< + Name extends string, + State extends Record, + messenger extends RestrictedControllerMessenger< + Name, + any, + PollingCompleteType | any, + string, + string + >, +> extends BaseControllerV2 { + readonly #intervalLength: number; + + private readonly networkClientIdTokensMap: Map> = + new Map(); + + private readonly intervalIds: Record = {}; + + constructor({ + name, + state, + messenger, + metadata, + pollingIntervalLength, + }: { + name: Name; + state: State; + metadata: StateMetadata; + messenger: messenger; + pollingIntervalLength: number; + }) { + super({ + name, + state, + messenger, + metadata, + }); + + if (!pollingIntervalLength) { + throw new Error('pollingIntervalLength required for PollingController'); + } + + this.#intervalLength = pollingIntervalLength; + } + + /** + * Starts polling for a networkClientId + * + * @param networkClientId - The networkClientId to start polling for + * @returns void + */ + start(networkClientId: NetworkClientId) { + const innerPollToken = random(); + if (this.networkClientIdTokensMap.has(networkClientId)) { + const set = this.networkClientIdTokensMap.get(networkClientId); + set?.add(innerPollToken); + } else { + const set = new Set(); + set.add(innerPollToken); + this.networkClientIdTokensMap.set(networkClientId, set); + } + this.#poll(networkClientId); + return innerPollToken; + } + + /** + * Stops polling for all networkClientIds + */ + stopAll() { + this.networkClientIdTokensMap.forEach((tokens, _networkClientId) => { + tokens.forEach((token) => { + this.stop(token); + }); + }); + } + + /** + * Stops polling for a networkClientId + * + * @param pollingToken - The polling token to stop polling for + */ + stop(pollingToken: string) { + if (!pollingToken) { + throw new Error('pollingToken required'); + } + let found = false; + this.networkClientIdTokensMap.forEach((tokens, networkClientId) => { + if (tokens.has(pollingToken)) { + found = true; + this.networkClientIdTokensMap + .get(networkClientId) + ?.delete(pollingToken); + if (this.networkClientIdTokensMap.get(networkClientId)?.size === 0) { + clearTimeout(this.intervalIds[networkClientId]); + delete this.intervalIds[networkClientId]; + this.networkClientIdTokensMap.delete(networkClientId); + this.messagingSystem.publish( + `${this.name}:pollingComplete`, + networkClientId, + ); + } + } + }); + if (!found) { + throw new Error('pollingToken not found'); + } + } + + /** + * Executes the poll for a networkClientId + * + * @param networkClientId - The networkClientId to execute the poll for + */ + abstract executePoll(networkClientId: NetworkClientId): Promise; + + #poll(networkClientId: NetworkClientId) { + if (this.intervalIds[networkClientId]) { + clearTimeout(this.intervalIds[networkClientId]); + delete this.intervalIds[networkClientId]; + } + this.intervalIds[networkClientId] = setTimeout(async () => { + try { + await this.executePoll(networkClientId); + } catch (error) { + console.error(error); + } + this.#poll(networkClientId); + }, this.#intervalLength); + } +} diff --git a/packages/polling-controller/src/index.ts b/packages/polling-controller/src/index.ts new file mode 100644 index 0000000000..1458c0cfe9 --- /dev/null +++ b/packages/polling-controller/src/index.ts @@ -0,0 +1 @@ +export { default as PollingController } from './PollingController'; diff --git a/packages/polling-controller/tsconfig.build.json b/packages/polling-controller/tsconfig.build.json new file mode 100644 index 0000000000..ac0df4920c --- /dev/null +++ b/packages/polling-controller/tsconfig.build.json @@ -0,0 +1,14 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [ + { "path": "../base-controller/tsconfig.build.json" }, + { "path": "../controller-utils/tsconfig.build.json" }, + { "path": "../network-controller/tsconfig.build.json" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/polling-controller/tsconfig.json b/packages/polling-controller/tsconfig.json new file mode 100644 index 0000000000..4bbb0be81b --- /dev/null +++ b/packages/polling-controller/tsconfig.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../base-controller" }, + { "path": "../controller-utils" }, + { "path": "../network-controller" } + ], + "include": ["../../types", "./src"] +} diff --git a/packages/polling-controller/typedoc.json b/packages/polling-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/polling-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/yarn.lock b/yarn.lock index c51530c5a7..056dd4d5f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2121,6 +2121,29 @@ __metadata: languageName: unknown linkType: soft +"@metamask/polling-controller@workspace:packages/polling-controller": + version: 0.0.0-use.local + resolution: "@metamask/polling-controller@workspace:packages/polling-controller" + dependencies: + "@metamask/auto-changelog": ^3.1.0 + "@metamask/base-controller": ^3.2.1 + "@metamask/controller-utils": ^5.0.0 + "@metamask/network-controller": ^13.0.0 + "@metamask/utils": ^6.2.0 + "@types/jest": ^27.4.1 + "@types/uuid": ^8.3.0 + deepmerge: ^4.2.2 + jest: ^27.5.1 + ts-jest: ^27.1.4 + typedoc: ^0.23.15 + typedoc-plugin-missing-exports: ^0.23.0 + typescript: ~4.6.3 + uuid: ^8.3.2 + peerDependencies: + "@metamask/network-controller": ^13.0.0 + languageName: unknown + linkType: soft + "@metamask/post-message-stream@npm:^6.1.1, @metamask/post-message-stream@npm:^6.1.2": version: 6.1.2 resolution: "@metamask/post-message-stream@npm:6.1.2"