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..7d90e0d0c7 --- /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/example-controllers` + +or + +`npm install @metamask/example-controllers` + +## 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..938c9b84f1 --- /dev/null +++ b/packages/remote-feature-flag-controller/package.json @@ -0,0 +1,74 @@ +{ + "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", + "publish:preview": "yarn npm publish --tag preview", + "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": { + "@lavamoat/allow-scripts": "^3.3.0", + "@metamask/base-controller": "^7.0.2", + "@metamask/utils": "^10.0.0" + }, + "devDependencies": { + "@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" + }, + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org/" + } +} 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..ca93d13e42 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.test.ts @@ -0,0 +1,192 @@ +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'; + +describe('ClientConfigApiService', () => { + let originalConsoleError: typeof console.error; + 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 }); + }); + + beforeAll(() => { + originalConsoleError = console.error; + console.error = jest + .spyOn(console, 'error') + .mockImplementation() as unknown as typeof console.error; + }); + + afterAll(() => { + console.error = originalConsoleError; + }); + + describe('fetchFlags', () => { + 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( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + + 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( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + 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( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + const currentTime = Date.now(); + expect(result).toStrictEqual({ + error: true, + message: 'Network error', + statusCode: '503', + statusText: 'Service Unavailable', + cachedData: {}, + cacheTimestamp: currentTime, + }); + }); + + it('should handle non-200 responses without cache data', async () => { + mockFetch.mockResolvedValueOnce({ + ok: false, + status: 404, + statusText: 'Not Found', + }); + + const result = await clientConfigApiService.fetchFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + 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( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + cachedData, + cacheTimestamp, + ); + 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 invalid API responses', async () => { + mockFetch.mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: 'OK', + json: async () => null, // Invalid response + }); + + const result = await clientConfigApiService.fetchFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + + const currentTime = Date.now(); + expect(result).toStrictEqual({ + error: true, + message: 'Invalid API response', + statusCode: null, + statusText: null, + cachedData: {}, + cacheTimestamp: currentTime, + }); + }); + }); +}); 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..7be5368acc --- /dev/null +++ b/packages/remote-feature-flag-controller/src/client-config-api-service/client-config-api-service.ts @@ -0,0 +1,111 @@ +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; +}; + +/** + * This service is responsible for fetching feature flags from the ClientConfig API. + */ +export class ClientConfigApiService { + #fetch: typeof fetch; + + #baseUrl = 'https://client-config.api.cx.metamask.io/v1'; + + /** + * Constructs a new ClientConfigApiService object. + * + * @param args - The arguments. + * @param args.fetch - A function that can be used to make an HTTP request. + */ + constructor({ fetch: fetchFunction }: { fetch: typeof fetch }) { + this.#fetch = fetchFunction; + } + + /** + * 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 client - The client type (e.g., 'extension', 'mobile'). + * @param distribution - The distribution type (e.g., 'main', 'flask'). + * @param environment - The environment type (e.g., 'prod', 'rc', 'dev'). + * @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( + client: ClientType, + distribution: DistributionType, + environment: EnvironmentType, + cachedData?: FeatureFlags, + cacheTimestamp?: number, + ): Promise { + const url = `${ + this.#baseUrl + }/flags?client=${client}&distribution=${distribution}&environment=${environment}`; + + try { + const response = await this.#fetch(url, { cache: 'no-cache' }); + + if (!response.ok) { + return { + error: true, + message: 'Failed to fetch flags', + statusCode: response.status.toString(), + 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); + // Return a structured error object + const err = error as Error & { + response?: { status: number; statusText: string }; + }; + + return { + error: true, + message: err.message, + 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..410dad9c91 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/index.ts @@ -0,0 +1,8 @@ +export { RemoteFeatureFlagController } from './remote-feature-flag-controller'; + +export type { + RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerGetStateAction, + FeatureFlags, +} from './remote-feature-flag-controller-types'; +export { getDefaultRemoteFeatureFlagControllerState } from './remote-feature-flag-controller-types'; 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..3a6496181c --- /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. + */ + flags: 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 { + flags: {}, + 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..2a46574841 --- /dev/null +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.test.ts @@ -0,0 +1,189 @@ +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, + RemoteFeatureFlagControllerStateChangeEvent, +} from './remote-feature-flag-controller'; +import { + ClientType, + DistributionType, + EnvironmentType, +} from './remote-feature-flag-controller-types'; + +const mockFlags = { + feature1: true, + feature2: { chrome: '<109' }, +}; + +describe('RemoteFeatureFlagController', () => { + describe('constructor', () => { + it('should initialize with default state', () => { + const controller = new RemoteFeatureFlagController({ + messenger: getControllerMessenger(), + clientConfigApiService: buildClientConfigApiService(), + }); + + expect(controller.state).toStrictEqual({ + flags: {}, + cacheTimestamp: 0, + }); + }); + }); + + describe('getFeatureFlags', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.useRealTimers(); + jest.clearAllMocks(); + }); + + it('should get feature flags from API when cache is invalid', async () => { + const clientConfigApiService = buildClientConfigApiService(); + const controller = new RemoteFeatureFlagController({ + messenger: getControllerMessenger(), + clientConfigApiService, + }); + + const flags = await controller.getFeatureFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + + expect(flags).toStrictEqual(mockFlags); + }); + + it('should use cached flags when cache is valid', async () => { + const clientConfigApiService = buildClientConfigApiService(); + const controller = new RemoteFeatureFlagController({ + messenger: getControllerMessenger(), + clientConfigApiService, + }); + + // First call to set cache + await controller.getFeatureFlags( + ClientType.Mobile, + DistributionType.Flask, + EnvironmentType.Development, + ); + + // Mock different response + jest + .spyOn(clientConfigApiService, 'fetchFlags') + .mockImplementation(async () => ({ + error: false, + message: 'Success', + statusCode: '200', + statusText: 'OK', + cachedData: { differentFlag: true }, + cacheTimestamp: Date.now(), + })); + + const flags = await controller.getFeatureFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + + expect(flags).toStrictEqual(mockFlags); + }); + + it('should handle concurrent flag updates', async () => { + const controller = new RemoteFeatureFlagController({ + messenger: getControllerMessenger(), + clientConfigApiService: buildClientConfigApiService(), + }); + + const [result1, result2] = await Promise.all([ + controller.getFeatureFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ), + controller.getFeatureFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ), + ]); + + 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 = new RemoteFeatureFlagController({ + messenger: getControllerMessenger(rootMessenger), + clientConfigApiService: buildClientConfigApiService(), + }); + + await controller.getFeatureFlags( + ClientType.Extension, + DistributionType.Main, + EnvironmentType.Production, + ); + + expect(stateChangeSpy).toHaveBeenCalled(); + expect(controller.state.flags).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..8c169de11d --- /dev/null +++ b/packages/remote-feature-flag-controller/src/remote-feature-flag-controller.ts @@ -0,0 +1,165 @@ +import type { + ControllerGetStateAction, + ControllerStateChangeEvent, + RestrictedControllerMessenger, + StateMetadata, +} 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, + ClientType, + DistributionType, + EnvironmentType, +} 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 = { + flags: FeatureFlags; + cacheTimestamp: number; +}; + +const remoteFeatureFlagControllerMetadata = { + flags: { persist: true, anonymous: false }, + cacheTimestamp: { persist: true, anonymous: true }, +} satisfies StateMetadata; + +// === MESSENGER === + +export type RemoteFeatureFlagControllerGetStateAction = + ControllerGetStateAction< + typeof controllerName, + RemoteFeatureFlagControllerState + >; + +export type RemoteFeatureFlagControllerGetFeatureFlagsAction = { + type: `${typeof controllerName}:getFeatureFlags`; + handler: RemoteFeatureFlagController['getFeatureFlags']; +}; + +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 { + flags: {}, + cacheTimestamp: 0, + }; +} + +// === CONTROLLER DEFINITION === + +export class RemoteFeatureFlagController extends BaseController< + typeof controllerName, + RemoteFeatureFlagControllerState, + RemoteFeatureFlagControllerMessenger +> { + readonly #fetchInterval: number; + + #clientConfigApiService: AbstractClientConfigApiService; + + #inProgressFlagUpdate?: Promise<{ cachedData: FeatureFlags }>; + + constructor({ + fetchInterval = DEFAULT_CACHE_DURATION, + messenger, + state, + clientConfigApiService, + }: { + fetchInterval?: number; + messenger: RemoteFeatureFlagControllerMessenger; + state?: Partial; + clientConfigApiService: AbstractClientConfigApiService; + }) { + super({ + name: controllerName, + metadata: remoteFeatureFlagControllerMetadata, + messenger, + state: { + ...getDefaultRemoteFeatureFlagControllerState(), + ...state, + }, + }); + + this.#fetchInterval = fetchInterval; + this.#clientConfigApiService = clientConfigApiService; + } + + private isCacheValid(): boolean { + return Date.now() - this.state.cacheTimestamp < this.#fetchInterval; + } + + async getFeatureFlags( + clientType: ClientType, + distributionType: DistributionType, + environmentType: EnvironmentType, + ): Promise { + if (this.isCacheValid()) { + return this.state.flags; + } + + if (this.#inProgressFlagUpdate) { + await this.#inProgressFlagUpdate; + } + + try { + this.#inProgressFlagUpdate = this.#clientConfigApiService.fetchFlags( + clientType, + distributionType, + environmentType, + ); + 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.flags; + } + + private updateCache(flags: FeatureFlags) { + const newState: RemoteFeatureFlagControllerState = { + flags, + cacheTimestamp: Date.now(), + } satisfies RemoteFeatureFlagControllerState; + + this.update(() => newState); + } +} 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..7211fee891 --- /dev/null +++ b/packages/remote-feature-flag-controller/tsconfig.build.json @@ -0,0 +1,12 @@ +{ + "extends": "../../tsconfig.packages.build.json", + "compilerOptions": { + "baseUrl": "./", + "outDir": "./dist/types", + "rootDir": "./src" + }, + "references": [ + { "path": "../../packages/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..9212e01362 --- /dev/null +++ b/packages/remote-feature-flag-controller/tsconfig.json @@ -0,0 +1,11 @@ +{ + "extends": "../../tsconfig.packages.json", + "compilerOptions": { + "baseUrl": "./" + }, + "references": [ + { "path": "../../packages/base-controller" }, + { "path": "../../packages/controller-utils" } + ], + "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 4c119b3c04..92c21b6084 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3457,6 +3457,25 @@ __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: + "@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" + 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, @metamask/rpc-errors@npm:^6.3.1": version: 6.3.1 resolution: "@metamask/rpc-errors@npm:6.3.1"