diff --git a/.idea/core.iml b/.idea/core.iml new file mode 100644 index 0000000000..d6ebd48059 --- /dev/null +++ b/.idea/core.iml @@ -0,0 +1,9 @@ + + + + + + + + + \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml new file mode 100644 index 0000000000..402ffa7e2b --- /dev/null +++ b/.idea/modules.xml @@ -0,0 +1,8 @@ + + + + + + + + \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml new file mode 100644 index 0000000000..35eb1ddfbb --- /dev/null +++ b/.idea/vcs.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/.idea/workspace.xml b/.idea/workspace.xml new file mode 100644 index 0000000000..3d959051ca --- /dev/null +++ b/.idea/workspace.xml @@ -0,0 +1,30 @@ + + + + + + + + + + + + + + + + + + + 1732151639949 + + + + \ No newline at end of file diff --git a/README.md b/README.md index d015ce114c..b1a3e5da8a 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ Each package in this repository has its own README where you can find installati - [`@metamask/profile-sync-controller`](packages/profile-sync-controller) - [`@metamask/queued-request-controller`](packages/queued-request-controller) - [`@metamask/rate-limit-controller`](packages/rate-limit-controller) +- [`@metamask/remote-feature-flag-controller`](packages/remote-feature-flag-controller) - [`@metamask/selected-network-controller`](packages/selected-network-controller) - [`@metamask/signature-controller`](packages/signature-controller) - [`@metamask/transaction-controller`](packages/transaction-controller) @@ -162,8 +163,8 @@ linkStyle default opacity:0.5 preferences_controller --> keyring_controller; profile_sync_controller --> base_controller; profile_sync_controller --> keyring_controller; - profile_sync_controller --> accounts_controller; profile_sync_controller --> network_controller; + profile_sync_controller --> accounts_controller; queued_request_controller --> base_controller; queued_request_controller --> controller_utils; queued_request_controller --> json_rpc_engine; @@ -179,6 +180,7 @@ linkStyle default opacity:0.5 signature_controller --> approval_controller; signature_controller --> keyring_controller; signature_controller --> logging_controller; + signature_controller --> network_controller; transaction_controller --> base_controller; transaction_controller --> controller_utils; transaction_controller --> accounts_controller; diff --git a/packages/remote-feature-flag-controller/CHANGELOG.md b/packages/remote-feature-flag-controller/CHANGELOG.md new file mode 100644 index 0000000000..057fc9fd4a --- /dev/null +++ b/packages/remote-feature-flag-controller/CHANGELOG.md @@ -0,0 +1,14 @@ +# 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). + +## [0.1.0] + +### Added + +- Initial release + +[Unreleased]: https://github.com/MetaMask/core/ diff --git a/packages/remote-feature-flag-controller/LICENSE b/packages/remote-feature-flag-controller/LICENSE new file mode 100644 index 0000000000..6f8bff03fc --- /dev/null +++ b/packages/remote-feature-flag-controller/LICENSE @@ -0,0 +1,20 @@ +MIT License + +Copyright (c) 2024 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/remote-feature-flag-controller/README.md b/packages/remote-feature-flag-controller/README.md new file mode 100644 index 0000000000..91b4f41d69 --- /dev/null +++ b/packages/remote-feature-flag-controller/README.md @@ -0,0 +1,15 @@ +# `@metamask/example-controllers` + +This package is designed to illustrate best practices for controller packages and controller files, including tests. + +## Installation + +`yarn add @metamask/remote-feature-flag-controller` + +or + +`npm install @metamask/remote-feature-flag-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/remote-feature-flag-controller/jest.config.js b/packages/remote-feature-flag-controller/jest.config.js new file mode 100644 index 0000000000..ca08413339 --- /dev/null +++ b/packages/remote-feature-flag-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: 100, + functions: 100, + lines: 100, + statements: 100, + }, + }, +}); diff --git a/packages/remote-feature-flag-controller/package.json b/packages/remote-feature-flag-controller/package.json new file mode 100644 index 0000000000..6dd3862e29 --- /dev/null +++ b/packages/remote-feature-flag-controller/package.json @@ -0,0 +1,70 @@ +{ + "name": "@metamask/remote-feature-flag-controller", + "version": "0.0.1", + "private": true, + "description": "Controller with caching, fallback, and privacy for managing feature flags via ClientConfigAPI", + "keywords": [ + "MetaMask", + "Ethereum" + ], + "homepage": "https://github.com/MetaMask/core/tree/main/packages/remote-feature-flag-controller#readme", + "bugs": { + "url": "https://github.com/MetaMask/core/issues" + }, + "repository": { + "type": "git", + "url": "https://github.com/MetaMask/core.git" + }, + "license": "MIT", + "sideEffects": false, + "exports": { + ".": { + "import": { + "types": "./dist/index.d.mts", + "default": "./dist/index.mjs" + }, + "require": { + "types": "./dist/index.d.cts", + "default": "./dist/index.cjs" + } + }, + "./package.json": "./package.json" + }, + "main": "./dist/index.cjs", + "types": "./dist/index.d.cts", + "files": [ + "dist/" + ], + "scripts": { + "build": "ts-bridge --project tsconfig.build.json --verbose --clean --no-references", + "build:docs": "typedoc", + "changelog:update": "../../scripts/update-changelog.sh @metamask/remote-feature-flag-controllers", + "changelog:validate": "../../scripts/validate-changelog.sh @metamask/remote-feature-flag-controllers", + "since-latest-release": "../../scripts/since-latest-release.sh", + "test": "NODE_OPTIONS=--experimental-vm-modules jest --reporters=jest-silent-reporter", + "test:clean": "NODE_OPTIONS=--experimental-vm-modules jest --clearCache", + "test:verbose": "NODE_OPTIONS=--experimental-vm-modules jest --verbose", + "test:watch": "NODE_OPTIONS=--experimental-vm-modules jest --watch" + }, + "dependencies": { + "@metamask/base-controller": "^7.0.2", + "@metamask/utils": "^10.0.0", + "cockatiel": "^3.1.2" + }, + "devDependencies": { + "@lavamoat/allow-scripts": "^3.0.4", + "@metamask/auto-changelog": "^3.4.4", + "@metamask/controller-utils": "^11.4.3", + "@types/jest": "^27.4.1", + "deepmerge": "^4.2.2", + "jest": "^27.5.1", + "nock": "^13.3.1", + "ts-jest": "^27.1.4", + "typedoc": "^0.24.8", + "typedoc-plugin-missing-exports": "^2.0.0", + "typescript": "~5.2.2" + }, + "engines": { + "node": "^18.18 || >=20" + } +} diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts new file mode 100644 index 0000000000..a7def3bef5 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/abstract-client-config-api-service.ts @@ -0,0 +1,9 @@ +import type { PublicInterface } from '@metamask/utils'; + +import type { ClientConfigApiService } from './client-config-api-service'; + +/** + * A service object responsible for fetching feature flags. + */ +export type AbstractClientConfigApiService = + PublicInterface; diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts new file mode 100644 index 0000000000..4d1932b0db --- /dev/null +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts @@ -0,0 +1,311 @@ +import type { FeatureFlags } from '../remote-feature-flag-controller-types'; +import { + ClientType, + DistributionType, + EnvironmentType, +} from '../remote-feature-flag-controller-types'; +import { ClientConfigApiService } from './client-config-api-service'; + +const BASE_URL = 'https://client-config.api.cx.metamask.io/v1'; + +// eslint-disable-next-line jest/prefer-spy-on +console.error = jest.fn(); + +describe('ClientConfigApiService', () => { + let clientConfigApiService: ClientConfigApiService; + let mockFetch: jest.Mock; + + const mockFeatureFlags: FeatureFlags = { + feature1: false, + feature2: { chrome: '<109' }, + }; + + const networkError = new Error('Network error'); + Object.assign(networkError, { + response: { + status: 503, + statusText: 'Service Unavailable', + }, + }); + + beforeEach(() => { + mockFetch = jest.fn(); + clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + retries: 0, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should successfully fetch and return feature flags', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => mockFeatureFlags, + }); + + const result = await clientConfigApiService.fetchFlags(); + + expect(mockFetch).toHaveBeenCalledWith( + `${BASE_URL}/flags?client=extension&distribution=main&environment=prod`, + { cache: 'no-cache' }, + ); + + expect(result).toStrictEqual({ + error: false, + message: 'Success', + statusCode: '200', + statusText: 'OK', + cachedData: mockFeatureFlags, + cacheTimestamp: expect.any(Number), + }); + }); + + it('should return cached data when API request fails and cached data is available', async () => { + const cachedData = { feature3: true }; + const cacheTimestamp = Date.now(); + + mockFetch.mockRejectedValueOnce(networkError); + + const result = await clientConfigApiService.fetchFlags( + cachedData, + cacheTimestamp, + ); + + expect(result).toStrictEqual({ + error: true, + message: 'Network error', + statusCode: '503', + statusText: 'Service Unavailable', + cachedData, + cacheTimestamp, + }); + }); + + it('should return empty object when API request fails and cached data is not available', async () => { + mockFetch.mockRejectedValueOnce(networkError); + const result = await clientConfigApiService.fetchFlags(); + + expect(result).toStrictEqual({ + error: true, + message: 'Network error', + statusCode: '503', + statusText: 'Service Unavailable', + cachedData: {}, + cacheTimestamp: expect.any(Number), + }); + }); + + it('should handle non-200 responses without cache data', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await clientConfigApiService.fetchFlags(); + const currentTime = Date.now(); + expect(result).toStrictEqual({ + error: true, + message: 'Failed to fetch flags', + statusCode: '404', + statusText: 'Not Found', + cachedData: {}, + cacheTimestamp: currentTime, + }); + }); + + it('should handle non-200 responses with cache data', async () => { + const cachedData = { feature3: true }; + const cacheTimestamp = Date.now(); + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await clientConfigApiService.fetchFlags( + cachedData, + cacheTimestamp, + ); + + expect(result).toStrictEqual({ + error: true, + message: 'Failed to fetch flags', + statusCode: '404', + statusText: 'Not Found', + cachedData, + cacheTimestamp, + }); + }); + + it('should handle invalid API responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => null, // Invalid response + }); + + const result = await clientConfigApiService.fetchFlags(); + + const currentTime = Date.now(); + expect(result).toStrictEqual({ + error: true, + message: 'Invalid API response', + statusCode: null, + statusText: null, + cachedData: {}, + cacheTimestamp: currentTime, + }); + }); + + it('should retry the fetch the specified number of times on failure', async () => { + const maxRetries = 3; + clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + retries: maxRetries, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + + // Mock fetch to fail every time + mockFetch.mockRejectedValue(networkError); + + const result = await clientConfigApiService.fetchFlags(); + const currentTime = Date.now(); + expect(result).toStrictEqual({ + error: true, + message: 'Network error', + statusCode: '503', + statusText: 'Service Unavailable', + cachedData: {}, + cacheTimestamp: currentTime, + }); + // Check that fetch was retried the correct number of times + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); // Initial + retries + }); + + it('should open the circuit breaker after consecutive failures', async () => { + const maxFailures = 3; // Set max consecutive failures for circuit breaker + clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + maximumConsecutiveFailures: maxFailures, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + + // Mock fetch to fail every time + mockFetch.mockRejectedValue(networkError); + + // Trigger fetch attempts + for (let i = 0; i < maxFailures; i++) { + await clientConfigApiService.fetchFlags(); + } + + const result = await clientConfigApiService.fetchFlags(); + + expect(result).toStrictEqual({ + error: true, + message: 'Execution prevented because the circuit breaker is open', + statusCode: null, + statusText: null, + cachedData: {}, + cacheTimestamp: expect.any(Number), + }); + + // Check that fetch was called for each failure before the circuit breaker opened + expect(mockFetch).toHaveBeenCalledTimes(maxFailures); + }); + + it('should call the onDegraded callback when requests are slow', async () => { + const onDegraded = jest.fn(); + const slowFetchTime = 5500; // Exceed the DEFAULT_DEGRADED_THRESHOLD (5000ms) + + clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + onDegraded, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + + // Mock fetch to take a long time + mockFetch.mockImplementation( + () => + new Promise((resolve) => + setTimeout( + () => + resolve({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => mockFeatureFlags, + }), + slowFetchTime, + ), + ), + ); + + await clientConfigApiService.fetchFlags(); + + // Verify the degraded callback was called + expect(onDegraded).toHaveBeenCalled(); + }); + + it('should succeed on a subsequent fetch attempt after retries', async () => { + const maxRetries = 2; + clientConfigApiService = new ClientConfigApiService({ + fetch: mockFetch, + retries: maxRetries, + config: { + client: ClientType.Extension, + distribution: DistributionType.Main, + environment: EnvironmentType.Production, + }, + }); + + // Mock fetch to fail initially, then succeed + mockFetch + .mockRejectedValueOnce(networkError) // First attempt fails + .mockRejectedValueOnce(networkError) // Second attempt fails + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => mockFeatureFlags, // Third attempt succeeds + }); + + const result = await clientConfigApiService.fetchFlags(); + + // Verify success on the third attempt + expect(result).toStrictEqual({ + error: false, + message: 'Success', + statusCode: '200', + statusText: 'OK', + cachedData: mockFeatureFlags, + cacheTimestamp: expect.any(Number), + }); + + // Verify fetch was retried the correct number of times + expect(mockFetch).toHaveBeenCalledTimes(maxRetries + 1); // Initial + retries + }); +}); diff --git a/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts new file mode 100644 index 0000000000..5e17a449a2 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts @@ -0,0 +1,197 @@ +import { + circuitBreaker, + ConsecutiveBreaker, + ExponentialBackoff, + handleAll, + type IPolicy, + retry, + wrap, + CircuitState, +} from 'cockatiel'; + +import type { + FeatureFlags, + ClientType, + DistributionType, + EnvironmentType, +} from '../remote-feature-flag-controller-types'; + +type ApiResponse = { + error: boolean; + message: string; + statusCode: string | null; + statusText: string | null; + cachedData: FeatureFlags; + cacheTimestamp: number | null; +}; + +const DEFAULT_FETCH_RETRIES = 3; +// Each update attempt will result (1 + retries) calls if the server is down +const DEFAULT_MAX_CONSECUTIVE_FAILURES = (1 + DEFAULT_FETCH_RETRIES) * 3; + +const DEFAULT_DEGRADED_THRESHOLD = 5000; + +/** + * This service is responsible for fetching feature flags from the ClientConfig API. + */ +export class ClientConfigApiService { + #fetch: typeof fetch; + + #policy: IPolicy; + + #baseUrl = 'https://client-config.api.cx.metamask.io/v1'; + + #client: ClientType; + + #distribution: DistributionType; + + #environment: EnvironmentType; + + /** + * Constructs a new ClientConfigApiService object. + * + * @param args - The arguments. + * @param args.fetch - A function that can be used to make an HTTP request. + * @param args.retries - Number of retry attempts for each fetch request. + * @param args.maximumConsecutiveFailures - The maximum number of consecutive failures + * allowed before breaking the circuit and pausing further fetch attempts. + * @param args.circuitBreakDuration - The duration for which the circuit remains open after + * too many consecutive failures. + * @param args.onBreak - Callback invoked when the circuit breaks. + * @param args.onDegraded - Callback invoked when the service is degraded (requests resolving too slowly). + * @param args.config - The configuration object, includes client, distribution, and environment. + * @param args.config.client - The client type (e.g., 'extension', 'mobile'). + * @param args.config.distribution - The distribution type (e.g., 'main', 'flask'). + * @param args.config.environment - The environment type (e.g., 'prod', 'rc', 'dev'). + */ + constructor({ + fetch: fetchFunction, + retries = DEFAULT_FETCH_RETRIES, + maximumConsecutiveFailures = DEFAULT_MAX_CONSECUTIVE_FAILURES, + circuitBreakDuration = 30 * 60 * 1000, + onBreak, + onDegraded, + config, + }: { + fetch: typeof fetch; + retries?: number; + maximumConsecutiveFailures?: number; + circuitBreakDuration?: number; + onBreak?: () => void; + onDegraded?: () => void; + config: { + client: ClientType; + distribution: DistributionType; + environment: EnvironmentType; + }; + }) { + this.#fetch = fetchFunction; + this.#client = config.client; + this.#distribution = config.distribution; + this.#environment = config.environment; + + const retryPolicy = retry(handleAll, { + maxAttempts: retries, + backoff: new ExponentialBackoff(), + }); + + const circuitBreakerPolicy = circuitBreaker(handleAll, { + halfOpenAfter: circuitBreakDuration, + breaker: new ConsecutiveBreaker(maximumConsecutiveFailures), + }); + + if (onBreak) { + circuitBreakerPolicy.onBreak(onBreak); + } + + if (onDegraded) { + retryPolicy.onGiveUp(() => { + if (circuitBreakerPolicy.state === CircuitState.Closed) { + onDegraded(); + } + }); + + retryPolicy.onSuccess(({ duration }) => { + if ( + circuitBreakerPolicy.state === CircuitState.Closed && + duration > DEFAULT_DEGRADED_THRESHOLD // Default degraded threshold + ) { + onDegraded(); + } + }); + } + + this.#policy = wrap(retryPolicy, circuitBreakerPolicy); + } + + /** + * Validate the API response. + * + * @param result - The result to validate. + * @throws Throws if the result is invalid. + */ + #validate(result: unknown): asserts result is FeatureFlags { + if (typeof result !== 'object' || result === null) { + throw new Error('Invalid API response'); + } + } + + /** + * Fetches feature flags from the API with specific client, distribution, and environment parameters. + * Provides structured error handling, including fallback to cached data if available. + * @param cachedData - cachedData from controller state + * @param cacheTimestamp - timestamp of data being cached from controller state + * @returns An object of feature flags and their boolean values or a structured error object. + */ + public async fetchFlags( + cachedData?: FeatureFlags, + cacheTimestamp?: number, + ): Promise { + const url = `${this.#baseUrl}/flags?client=${this.#client}&distribution=${ + this.#distribution + }&environment=${this.#environment}`; + + try { + const response = await this.#policy.execute(() => + this.#fetch(url, { cache: 'no-cache' }), + ); + + if (!response || !response.ok) { + return { + error: true, + message: 'Failed to fetch flags', + statusCode: response?.status?.toString() || null, + statusText: response?.statusText || 'Error', + cachedData: cachedData || {}, + cacheTimestamp: cacheTimestamp ?? Date.now(), + }; + } + + const data = await response.json(); + this.#validate(data); + + return { + error: false, + message: 'Success', + statusCode: response.status.toString(), + statusText: response.statusText || 'OK', + cachedData: data, + cacheTimestamp: Date.now(), + }; + } catch (error) { + console.error('Feature flag API request failed:', error); + + const err = error as Error & { + response?: { status: number; statusText: string }; + }; + return { + error: true, + message: err.message || 'Unknown error', + statusCode: err.response?.status?.toString() || null, + statusText: err.response?.statusText || null, + cachedData: cachedData || {}, // Return cached data if available + cacheTimestamp: cacheTimestamp || Date.now(), + }; + } + } +} diff --git a/packages/remote-feature-flag-controller/src/index.ts b/packages/remote-feature-flag-controller/src/index.ts new file mode 100644 index 0000000000..56b3d6bf57 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -0,0 +1,12 @@ +export { RemoteFeatureFlagController } from './remote-feature-flag-controller'; + +export type { + RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerGetStateAction, + FeatureFlags, + ClientType, + DistributionType, + EnvironmentType, +} from './remote-feature-flag-controller-types'; +export { getDefaultRemoteFeatureFlagControllerState } from './remote-feature-flag-controller-types'; +export { ClientConfigApiService } from './client-config-api-service/client-config-api-service'; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts new file mode 100644 index 0000000000..d7a21b8d67 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller-types.ts @@ -0,0 +1,57 @@ +import type { ControllerGetStateAction } from '@metamask/base-controller'; +import type { Json } from '@metamask/utils'; + +// Define accepted values for client, distribution, and environment +export enum ClientType { + Extension = 'extension', + Mobile = 'mobile', +} + +export enum DistributionType { + Main = 'main', + Flask = 'flask', +} + +export enum EnvironmentType { + Production = 'prod', + ReleaseCandidate = 'rc', + Development = 'dev', +} + +/** Type representing the feature flags collection */ +export type FeatureFlags = Record; + +/** + * Describes the shape of the state object for the {@link RemoteFeatureFlagController}. + */ +export type RemoteFeatureFlagControllerState = { + /** + * The collection of feature flags and their respective values, which can be objects. + */ + remoteFeatureFlags: FeatureFlags; + /** + * The timestamp of the last successful feature flag cache. + */ + cacheTimestamp: number; +}; + +/** + * Constructs the default state for the {@link RemoteFeatureFlagController}. + * + * @returns The default {@link RemoteFeatureFlagController} state. + */ +export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState { + return { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }; +} + +/** + * The action to retrieve the state of the {@link RemoteFeatureFlagController}. + */ +export type RemoteFeatureFlagControllerGetStateAction = + ControllerGetStateAction< + 'RemoteFeatureFlagController', + RemoteFeatureFlagControllerState + >; diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts new file mode 100644 index 0000000000..259df18af1 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -0,0 +1,241 @@ +import { ControllerMessenger } from '@metamask/base-controller'; + +import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; +import { + RemoteFeatureFlagController, + controllerName, +} from './remote-feature-flag-controller'; +import type { + RemoteFeatureFlagControllerActions, + RemoteFeatureFlagControllerMessenger, + RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerStateChangeEvent, +} from './remote-feature-flag-controller'; + +const mockFlags = { + feature1: true, + feature2: { chrome: '<109' }, +}; + +/** + * Creates a controller instance with default parameters for testing + */ +/** + * Creates a controller instance with default parameters for testing + * @param options - The controller configuration options + * @param options.messenger - The controller messenger instance + * @param options.state - The initial controller state + * @param options.clientConfigApiService - The client config API service instance + * @param options.disabled - Whether the controller should start disabled + * @returns A configured RemoteFeatureFlagController instance + */ +function createController( + options: Partial<{ + messenger: RemoteFeatureFlagControllerMessenger; + state: Partial; + clientConfigApiService: AbstractClientConfigApiService; + disabled: boolean; + }> = {}, +) { + return new RemoteFeatureFlagController({ + messenger: options.messenger ?? getControllerMessenger(), + state: options.state ?? {}, + clientConfigApiService: + options.clientConfigApiService ?? buildClientConfigApiService(), + disabled: options.disabled, + }); +} + +describe('RemoteFeatureFlagController', () => { + let clientConfigApiService: AbstractClientConfigApiService; + + beforeEach(() => { + clientConfigApiService = buildClientConfigApiService(); + jest.clearAllMocks(); + }); + + describe('constructor', () => { + it('should initialize with default state', () => { + const controller = createController(); + + expect(controller.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + + it('should initialize with disabled parameter', () => { + const controller = createController({ disabled: true }); + + expect(controller.state).toStrictEqual({ + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }); + }); + }); + + describe('getRemoteFeatureFlags', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should get feature flags from API when cache is invalid', async () => { + const controller = createController({ clientConfigApiService }); + + const flags = await controller.getRemoteFeatureFlags(); + + expect(flags).toStrictEqual(mockFlags); + }); + + it('should return empty object when disabled', async () => { + const controller = createController({ + clientConfigApiService, + disabled: true, + }); + + const remoteFeatureFlags = await controller.getRemoteFeatureFlags(); + + expect(remoteFeatureFlags).toStrictEqual({}); + expect(clientConfigApiService.fetchFlags).not.toHaveBeenCalled(); + }); + + it('should properly enable controller and make network request', async () => { + const controller = createController({ + clientConfigApiService, + disabled: true, + }); + + controller.enable(); + + const remoteFeatureFlags = await controller.getRemoteFeatureFlags(); + expect(remoteFeatureFlags).toStrictEqual(mockFlags); + expect(clientConfigApiService.fetchFlags).toHaveBeenCalledTimes(1); + }); + + it('should properly disable controller and not make network request', async () => { + const controller = createController({ + clientConfigApiService, + disabled: false, + }); + + controller.disable(); + + const remoteFeatureFlags = await controller.getRemoteFeatureFlags(); + expect(remoteFeatureFlags).toStrictEqual({}); + expect(clientConfigApiService.fetchFlags).not.toHaveBeenCalled(); + }); + + it('should not affect existing cache when toggling disabled state', async () => { + const controller = createController({ + clientConfigApiService, + disabled: false, + }); + + // First, enable and get flags to populate cache + await controller.getRemoteFeatureFlags(); + expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags); + + // Then disable and verify cache remains but is not accessible + controller.disable(); + const remoteFeatureFlags = await controller.getRemoteFeatureFlags(); + expect(remoteFeatureFlags).toStrictEqual({}); + expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags); + }); + + it('should use cached flags when cache is valid', async () => { + const controller = createController({ clientConfigApiService }); + + // First call to set cache + await controller.getRemoteFeatureFlags(); + + // Mock different response + jest + .spyOn(clientConfigApiService, 'fetchFlags') + .mockImplementation(async () => ({ + error: false, + message: 'Success', + statusCode: '200', + statusText: 'OK', + cachedData: { differentFlag: true }, + cacheTimestamp: Date.now(), + })); + + const remoteFeatureFlags = await controller.getRemoteFeatureFlags(); + + expect(remoteFeatureFlags).toStrictEqual(mockFlags); + }); + + it('should handle concurrent flag updates', async () => { + const controller = createController(); + + const [result1, result2] = await Promise.all([ + controller.getRemoteFeatureFlags(), + controller.getRemoteFeatureFlags(), + ]); + + expect(result1).toStrictEqual(mockFlags); + expect(result2).toStrictEqual(mockFlags); + }); + + it('should emit state change when updating cache', async () => { + const rootMessenger = getRootControllerMessenger(); + const stateChangeSpy = jest.fn(); + rootMessenger.subscribe(`${controllerName}:stateChange`, stateChangeSpy); + + const controller = createController({ + messenger: getControllerMessenger(rootMessenger), + }); + + await controller.getRemoteFeatureFlags(); + + expect(stateChangeSpy).toHaveBeenCalled(); + expect(controller.state.remoteFeatureFlags).toStrictEqual(mockFlags); + }); + }); +}); + +type RootAction = RemoteFeatureFlagControllerActions; +type RootEvent = RemoteFeatureFlagControllerStateChangeEvent; + +/** + * Creates and returns a root controller messenger for testing + * @returns A controller messenger instance + */ +function getRootControllerMessenger(): ControllerMessenger< + RootAction, + RootEvent +> { + return new ControllerMessenger(); +} + +/** + * Creates a restricted controller messenger for testing + * @param rootMessenger - The root messenger to restrict + * @returns A restricted controller messenger instance + */ +function getControllerMessenger( + rootMessenger = getRootControllerMessenger(), +): RemoteFeatureFlagControllerMessenger { + return rootMessenger.getRestricted({ + name: controllerName, + allowedActions: [], + allowedEvents: [], + }); +} + +/** + * Builds a mock client config API service for testing + * @returns A mock client config API service + */ +function buildClientConfigApiService(): AbstractClientConfigApiService { + return { + fetchFlags: jest.fn().mockResolvedValue({ + error: false, + message: 'Success', + statusCode: '200', + statusText: 'OK', + cachedData: mockFlags, + cacheTimestamp: Date.now(), + }), + }; +} diff --git a/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts new file mode 100644 index 0000000000..d74a273651 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -0,0 +1,174 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, +} from '@metamask/base-controller'; +import { BaseController } from '@metamask/base-controller'; + +import type { AbstractClientConfigApiService } from './client-config-api-service/abstract-client-config-api-service'; +import type { FeatureFlags } from './remote-feature-flag-controller-types'; + +// === GENERAL === + +export const controllerName = 'RemoteFeatureFlagController'; +const DEFAULT_CACHE_DURATION = 24 * 60 * 60 * 1000; // 1 day + +// === STATE === + +export type RemoteFeatureFlagControllerState = { + remoteFeatureFlags: FeatureFlags; + cacheTimestamp: number; +}; + +const remoteFeatureFlagControllerMetadata = { + remoteFeatureFlags: { persist: true, anonymous: false }, + cacheTimestamp: { persist: true, anonymous: true }, +}; + +// === MESSENGER === + +export type RemoteFeatureFlagControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + RemoteFeatureFlagControllerState + >; + +export type RemoteFeatureFlagControllerGetRemoteFeatureFlagsAction = { + type: `${typeof controllerName}:getRemoteFeatureFlags`; + handler: RemoteFeatureFlagController['getRemoteFeatureFlags']; +}; + +export type RemoteFeatureFlagControllerActions = + RemoteFeatureFlagControllerGetStateAction; + +export type AllowedActions = never; + +export type RemoteFeatureFlagControllerStateChangeEvent = + ControllerStateChangeEvent< + typeof controllerName, + RemoteFeatureFlagControllerState + >; + +export type RemoteFeatureFlagControllerEvents = + RemoteFeatureFlagControllerStateChangeEvent; + +export type AllowedEvents = never; + +export type RemoteFeatureFlagControllerMessenger = + RestrictedControllerMessenger< + typeof controllerName, + RemoteFeatureFlagControllerActions | AllowedActions, + RemoteFeatureFlagControllerEvents | AllowedEvents, + AllowedActions['type'], + AllowedEvents['type'] + >; + +/** + * Returns the default state for the RemoteFeatureFlagController + * @returns The default controller state + */ +export function getDefaultRemoteFeatureFlagControllerState(): RemoteFeatureFlagControllerState { + return { + remoteFeatureFlags: {}, + cacheTimestamp: 0, + }; +} + +// === CONTROLLER DEFINITION === + +export class RemoteFeatureFlagController extends BaseController< + typeof controllerName, + RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerMessenger +> { + readonly #fetchInterval: number; + + #disabled: boolean; + + #clientConfigApiService: AbstractClientConfigApiService; + + #inProgressFlagUpdate?: Promise<{ cachedData: FeatureFlags }>; + + constructor({ + messenger, + state, + clientConfigApiService, + fetchInterval = DEFAULT_CACHE_DURATION, + disabled = false, + }: { + messenger: RemoteFeatureFlagControllerMessenger; + state: Partial; + clientConfigApiService: AbstractClientConfigApiService; + fetchInterval?: number; + disabled?: boolean; + }) { + super({ + name: controllerName, + metadata: remoteFeatureFlagControllerMetadata, + messenger, + state: { + ...getDefaultRemoteFeatureFlagControllerState(), + ...state, + }, + }); + + this.#fetchInterval = fetchInterval; + this.#disabled = disabled; + this.#clientConfigApiService = clientConfigApiService; + } + + private isCacheValid(): boolean { + return Date.now() - this.state.cacheTimestamp < this.#fetchInterval; + } + + async getRemoteFeatureFlags(): Promise { + if (this.#disabled) { + return {}; + } + + if (this.isCacheValid()) { + return this.state.remoteFeatureFlags; + } + + if (this.#inProgressFlagUpdate) { + await this.#inProgressFlagUpdate; + } + + try { + this.#inProgressFlagUpdate = this.#clientConfigApiService.fetchFlags(); + const flags = await this.#inProgressFlagUpdate; + + if (Object.keys(flags.cachedData).length > 0) { + this.updateCache(flags.cachedData); + return flags.cachedData; + } + } finally { + this.#inProgressFlagUpdate = undefined; + } + + return this.state.remoteFeatureFlags; + } + + private updateCache(remoteFeatureFlags: FeatureFlags) { + const newState: RemoteFeatureFlagControllerState = { + remoteFeatureFlags, + cacheTimestamp: Date.now(), + }; + + this.update(() => newState); + } + + /** + * Allows controller to make network request + */ + enable(): void { + this.#disabled = false; + } + + /** + * Blocks controller from making network request + */ + disable(): void { + this.#disabled = true; + } +} diff --git a/packages/remote-feature-flag-controller/tsconfig.build.json b/packages/remote-feature-flag-controller/tsconfig.build.json new file mode 100644 index 0000000000..e5fd7422b9 --- /dev/null +++ b/packages/remote-feature-flag-controller/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist", + "rootDir": "./src" + }, + "references": [{ "path": "../base-controller/tsconfig.build.json" }], + "include": ["../../types", "./src"] +} diff --git a/packages/remote-feature-flag-controller/tsconfig.json b/packages/remote-feature-flag-controller/tsconfig.json new file mode 100644 index 0000000000..831cc7b867 --- /dev/null +++ b/packages/remote-feature-flag-controller/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [{ "path": "../../packages/base-controller" }], + "include": ["../../types", "./src"] +} diff --git a/packages/remote-feature-flag-controller/typedoc.json b/packages/remote-feature-flag-controller/typedoc.json new file mode 100644 index 0000000000..c9da015dbf --- /dev/null +++ b/packages/remote-feature-flag-controller/typedoc.json @@ -0,0 +1,7 @@ +{ + "entryPoints": ["./src/index.ts"], + "excludePrivate": true, + "hideGenerator": true, + "out": "docs", + "tsconfig": "./tsconfig.build.json" +} diff --git a/tsconfig.build.json b/tsconfig.build.json index 6102878c56..12d1120de3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -32,6 +32,7 @@ { "path": "./packages/profile-sync-controller/tsconfig.build.json" }, { "path": "./packages/queued-request-controller/tsconfig.build.json" }, { "path": "./packages/rate-limit-controller/tsconfig.build.json" }, + { "path": "./packages/remote-feature-flag-controller/tsconfig.build.json" }, { "path": "./packages/selected-network-controller/tsconfig.build.json" }, { "path": "./packages/signature-controller/tsconfig.build.json" }, { "path": "./packages/transaction-controller/tsconfig.build.json" }, diff --git a/tsconfig.json b/tsconfig.json index 127a643b9d..f0c8f813c4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -30,6 +30,7 @@ { "path": "./packages/profile-sync-controller" }, { "path": "./packages/queued-request-controller" }, { "path": "./packages/rate-limit-controller" }, + { "path": "./packages/remote-feature-flag-controller" }, { "path": "./packages/selected-network-controller" }, { "path": "./packages/signature-controller" }, { "path": "./packages/transaction-controller" }, diff --git a/yarn.lock b/yarn.lock index 21b527d7fc..7f21918924 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3437,6 +3437,27 @@ __metadata: languageName: unknown linkType: soft +"@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller": + version: 0.0.0-use.local + resolution: "@metamask/remote-feature-flag-controller@workspace:packages/remote-feature-flag-controller" + dependencies: + "@lavamoat/allow-scripts": "npm:^3.0.4" + "@metamask/auto-changelog": "npm:^3.4.4" + "@metamask/base-controller": "npm:^7.0.2" + "@metamask/controller-utils": "npm:^11.4.3" + "@metamask/utils": "npm:^10.0.0" + "@types/jest": "npm:^27.4.1" + cockatiel: "npm:^3.1.2" + deepmerge: "npm:^4.2.2" + jest: "npm:^27.5.1" + nock: "npm:^13.3.1" + ts-jest: "npm:^27.1.4" + typedoc: "npm:^0.24.8" + typedoc-plugin-missing-exports: "npm:^2.0.0" + typescript: "npm:~5.2.2" + languageName: unknown + linkType: soft + "@metamask/rpc-errors@npm:^6.2.1": version: 6.3.1 resolution: "@metamask/rpc-errors@npm:6.3.1"