diff --git a/.github/component_owners.yml b/.github/component_owners.yml index f04b4b2a8..1c93b6abc 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -20,6 +20,10 @@ components: libs/providers/flagd-web: - beeme1mr - toddbaert + libs/providers/flipt: + - markphelps + libs/providers/flipt-web: + - markphelps libs/providers/go-feature-flag: - thomaspoignant libs/providers/go-feature-flag-web: @@ -28,10 +32,8 @@ components: - kinyoklion - mateoc - sago2k8 - libs/providers/flipt: - - markphelps - libs/providers/flipt-web: - - markphelps + libs/providers/rocketflag: + - jgunnink libs/providers/unleash-web: - jarebudev diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 2b6655d9d..980be8e2f 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -21,5 +21,6 @@ "libs/shared/config-cat-core": "0.1.1", "libs/providers/unleash-web": "0.1.1", "libs/providers/growthbook": "0.1.2", - "libs/providers/aws-ssm": "0.1.3" + "libs/providers/aws-ssm": "0.1.3", + "libs/providers/rocketflag": "0.1.0" } diff --git a/libs/providers/rocketflag/.eslintrc.json b/libs/providers/rocketflag/.eslintrc.json new file mode 100644 index 000000000..356462f02 --- /dev/null +++ b/libs/providers/rocketflag/.eslintrc.json @@ -0,0 +1,30 @@ +{ + "extends": "../../../.eslintrc.json", + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.json"], + "parser": "jsonc-eslint-parser", + "rules": { + "@nx/dependency-checks": [ + "error", + { + "ignoredFiles": ["{projectRoot}/eslint.config.{js,cjs,mjs}"] + } + ] + } + } + ] +} diff --git a/libs/providers/rocketflag/README.md b/libs/providers/rocketflag/README.md new file mode 100644 index 000000000..7559387cb --- /dev/null +++ b/libs/providers/rocketflag/README.md @@ -0,0 +1,15 @@ +# RocketFlag Provider + +## Installation + +``` +$ npm install @openfeature/rocketflag-provider +``` + +## Building + +Run `npx nx run RocketFlag:package` to build the library. + +## Running unit tests + +Run `npx nx run RocketFlag:test` to execute the unit tests via [Jest](https://jestjs.io) from the root of the repo. diff --git a/libs/providers/rocketflag/babel.config.json b/libs/providers/rocketflag/babel.config.json new file mode 100644 index 000000000..d7bf474d1 --- /dev/null +++ b/libs/providers/rocketflag/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": [["minify", { "builtIns": false }]] +} diff --git a/libs/providers/rocketflag/jest.config.ts b/libs/providers/rocketflag/jest.config.ts new file mode 100644 index 000000000..8f2426419 --- /dev/null +++ b/libs/providers/rocketflag/jest.config.ts @@ -0,0 +1,9 @@ +export default { + displayName: 'RocketFlag', + preset: '../../../jest.preset.js', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/providers/rocketflag', +}; diff --git a/libs/providers/rocketflag/package.json b/libs/providers/rocketflag/package.json new file mode 100644 index 000000000..b17dc9ab9 --- /dev/null +++ b/libs/providers/rocketflag/package.json @@ -0,0 +1,17 @@ +{ + "name": "@openfeature/rocketflag-provider", + "version": "0.1.0", + "dependencies": { + "tslib": "^2.3.0" + }, + "main": "./src/index.js", + "typings": "./src/index.d.ts", + "scripts": { + "publish-if-not-exists": "cp $NPM_CONFIG_USERCONFIG .npmrc && if [ \"$(npm show $npm_package_name@$npm_package_version version)\" = \"$(npm run current-version -s)\" ]; then echo 'already published, skipping'; else npm publish --access public; fi", + "current-version": "echo $npm_package_version" + }, + "license": "Apache-2.0", + "peerDependencies": { + "@openfeature/web-sdk": "^1.6.0" + } +} diff --git a/libs/providers/rocketflag/project.json b/libs/providers/rocketflag/project.json new file mode 100644 index 000000000..848cbe75d --- /dev/null +++ b/libs/providers/rocketflag/project.json @@ -0,0 +1,77 @@ +{ + "name": "RocketFlag", + "$schema": "../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/providers/src", + "projectType": "library", + "release": { + "version": { + "generatorOptions": { + "packageRoot": "dist/{projectRoot}", + "currentVersionResolver": "git-tag" + } + } + }, + "tags": [], + "targets": { + "nx-release-publish": { + "options": { + "packageRoot": "dist/{projectRoot}" + } + }, + "lint": { + "executor": "@nx/eslint:lint" + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "{projectRoot}/jest.config.ts" + } + }, + "package": { + "executor": "@nx/rollup:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "project": "libs/providers/rocketflag/package.json", + "outputPath": "dist/libs/providers/rocketflag", + "entryFile": "libs/providers/rocketflag/src/index.ts", + "tsConfig": "libs/providers/rocketflag/tsconfig.lib.json", + "compiler": "tsc", + "generateExportsField": true, + "umdName": "RocketFlag", + "external": "all", + "format": ["cjs", "esm"], + "assets": [ + { + "glob": "package.json", + "input": "./assets", + "output": "./src/" + }, + { + "glob": "LICENSE", + "input": "./", + "output": "./" + }, + { + "glob": "README.md", + "input": "./libs/providers/rocketflag", + "output": "./" + } + ] + } + }, + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "npm run publish-if-not-exists", + "cwd": "dist/libs/providers/rocketflag" + }, + "dependsOn": [ + { + "projects": "self", + "target": "package" + } + ] + } + } +} diff --git a/libs/providers/rocketflag/src/index.ts b/libs/providers/rocketflag/src/index.ts new file mode 100644 index 000000000..062d553b3 --- /dev/null +++ b/libs/providers/rocketflag/src/index.ts @@ -0,0 +1 @@ +export * from './lib/rocketflag-provider'; diff --git a/libs/providers/rocketflag/src/lib/rocketflag-provider.spec.ts b/libs/providers/rocketflag/src/lib/rocketflag-provider.spec.ts new file mode 100644 index 000000000..65db7454b --- /dev/null +++ b/libs/providers/rocketflag/src/lib/rocketflag-provider.spec.ts @@ -0,0 +1,116 @@ +import type { EvaluationContext } from '@openfeature/web-sdk'; +import { OpenFeature, StandardResolutionReasons, ErrorCode } from '@openfeature/web-sdk'; +import type { FlagStatus, UserContext } from './rocketflag-provider'; +import { createRocketFlagProvider } from './rocketflag-provider'; + +// Create a mock RocketFlag client for testing +const mockClient = { + getFlag: jest.fn, [string, UserContext]>(), +}; + +// Mock Logger +const mockLogger = { + info: jest.fn(), + warn: jest.fn(), + error: jest.fn(), + debug: jest.fn(), +}; + +describe('RocketFlagProvider', () => { + beforeEach(() => jest.clearAllMocks()); + + it('should have the correct metadata name', () => { + const provider = createRocketFlagProvider(mockClient); + expect(provider.metadata.name).toBe('RocketFlagProvider'); + }); + + describe('resolveBooleanEvaluation', () => { + it('should return STALE initially, then resolve to the correct value with TARGETING_MATCH', async () => { + const provider = createRocketFlagProvider(mockClient); + const flagKey = 'test-flag-targeting'; + const targetingContext: EvaluationContext = { targetingKey: 'user@example.com' }; + + mockClient.getFlag.mockResolvedValue({ enabled: true }); + + const initialDetails = provider.resolveBooleanEvaluation(flagKey, false, targetingContext, mockLogger); + expect(initialDetails.reason).toBe(StandardResolutionReasons.STALE); + expect(initialDetails.value).toBe(false); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const finalDetails = provider.resolveBooleanEvaluation(flagKey, false, targetingContext, mockLogger); + + expect(finalDetails.value).toBe(true); + expect(finalDetails.reason).toBe(StandardResolutionReasons.TARGETING_MATCH); + expect(mockClient.getFlag).toHaveBeenCalledWith(flagKey, { cohort: 'user@example.com' }); + expect(mockClient.getFlag).toHaveBeenCalledTimes(2); + }); + + it('should return STALE initially, then resolve with DEFAULT reason when no targetingKey is provided', async () => { + const provider = createRocketFlagProvider(mockClient); + const flagKey = 'test-flag-default'; + + mockClient.getFlag.mockResolvedValue({ enabled: true }); + + const initialDetails = provider.resolveBooleanEvaluation(flagKey, false, {}, mockLogger); + expect(initialDetails.reason).toBe(StandardResolutionReasons.STALE); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const finalDetails = provider.resolveBooleanEvaluation(flagKey, false, {}, mockLogger); + + expect(finalDetails.value).toBe(true); + expect(finalDetails.reason).toBe(StandardResolutionReasons.DEFAULT); + expect(mockClient.getFlag).toHaveBeenCalledWith(flagKey, {}); + }); + + it('should return STALE initially, then resolve with an ERROR if the client rejects', async () => { + const provider = createRocketFlagProvider(mockClient); + OpenFeature.setProvider(provider); + const client = OpenFeature.getClient(); + const flagKey = 'test-flag-error'; + const errorMessage = 'Network error'; + + mockClient.getFlag.mockRejectedValue(new Error(errorMessage)); + + const initialDetails = provider.resolveBooleanEvaluation(flagKey, false, {}, mockLogger); + expect(initialDetails.reason).toBe(StandardResolutionReasons.STALE); + + await new Promise((resolve) => setTimeout(resolve, 0)); + + const finalDetails = client.getBooleanDetails(flagKey, false); + + expect(finalDetails.value).toBe(false); // Default value + expect(finalDetails.reason).toBe(StandardResolutionReasons.ERROR); + expect(finalDetails.errorCode).toBe(ErrorCode.GENERAL); + expect(finalDetails.errorMessage).toBe(errorMessage); + }); + }); + + // Tests for other evaluation types to ensure they return TYPE_MISMATCH + describe('Unsupported Evaluations', () => { + const provider = createRocketFlagProvider(mockClient); + + it('resolveStringEvaluation should return TYPE_MISMATCH error', () => { + const details = provider.resolveStringEvaluation('flag', 'default', {}, mockLogger); + expect(details.reason).toBe(StandardResolutionReasons.ERROR); + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + expect(details.value).toBe('default'); + }); + + it('resolveNumberEvaluation should return TYPE_MISMATCH error', () => { + const details = provider.resolveNumberEvaluation('flag', 123, {}, mockLogger); + expect(details.reason).toBe(StandardResolutionReasons.ERROR); + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + expect(details.value).toBe(123); + }); + + it('resolveObjectEvaluation should return TYPE_MISMATCH error', () => { + const defaultValue = { key: 'value' }; + const details = provider.resolveObjectEvaluation('flag', defaultValue, {}, mockLogger); + expect(details.reason).toBe(StandardResolutionReasons.ERROR); + expect(details.errorCode).toBe(ErrorCode.TYPE_MISMATCH); + expect(details.value).toEqual(defaultValue); + }); + }); +}); diff --git a/libs/providers/rocketflag/src/lib/rocketflag-provider.ts b/libs/providers/rocketflag/src/lib/rocketflag-provider.ts new file mode 100644 index 000000000..b8f6879ba --- /dev/null +++ b/libs/providers/rocketflag/src/lib/rocketflag-provider.ts @@ -0,0 +1,141 @@ +import type { EvaluationContext, Provider, JsonValue, ResolutionDetails, Logger } from '@openfeature/web-sdk'; +import { ErrorCode, ProviderEvents, StandardResolutionReasons } from '@openfeature/web-sdk'; +import { EventEmitter } from 'events'; + +export interface UserContext { + cohort?: string; +} + +export interface FlagStatus { + enabled: boolean; +} + +interface RocketFlagClient { + getFlag(flagKey: string, userContext: UserContext): Promise; +} + +/** + * A helper function to fetch a flag, update the cache, and notify OpenFeature of changes. + * It's defined within the factory's scope to access the client, cache, emitter, and logger. + */ +const fetchFlagAndUpdateCache = ( + // Parameters required for the fetch operation + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + cacheKey: string, + // State managed by the factory's closure + client: RocketFlagClient, + cache: Map>, + emitter: EventEmitter, + logger?: Logger, +) => { + const userContext: UserContext = {}; + const { targetingKey } = context; + + if (targetingKey && typeof targetingKey === 'string' && targetingKey !== '') { + userContext.cohort = targetingKey; + } + + client + .getFlag(flagKey, userContext) + .then((flagStatus) => { + const details: ResolutionDetails = { + value: flagStatus.enabled, + reason: userContext.cohort ? StandardResolutionReasons.TARGETING_MATCH : StandardResolutionReasons.DEFAULT, + }; + cache.set(cacheKey, details); + emitter.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: [flagKey] }); + logger?.debug(`Successfully fetched flag: ${flagKey}`); + }) + .catch((error: unknown) => { + const err = error instanceof Error ? error : new Error(String(error)); + const details: ResolutionDetails = { + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.GENERAL, + errorMessage: err.message, + }; + cache.set(cacheKey, details); + emitter.emit(ProviderEvents.ConfigurationChanged, { flagsChanged: [flagKey] }); + logger?.error(`Error fetching flag: ${flagKey}`, err); + }); +}; + +/** + * Creates a functional OpenFeature provider for RocketFlag. + * This provider resolves boolean flags from the RocketFlag service. + * + * @param {RocketFlagClient} client - An instance of the RocketFlag client. + * @returns {Provider & EventEmitter} A provider instance that can be used with the OpenFeature SDK. + */ +export function createRocketFlagProvider(client: RocketFlagClient): Provider & EventEmitter { + const emitter = new EventEmitter(); + const cache = new Map>(); + let logger: Logger | undefined; + + // Define the provider's logic in a plain object. + const providerLogic = { + metadata: { + name: 'RocketFlagProvider', + }, + runsOn: 'client' as const, + hooks: [], + + initialize: async (_context: EvaluationContext, initLogger?: Logger): Promise => { + logger = initLogger; + logger?.debug('Initialising RocketFlagProvider...'); + }, + + resolveBooleanEvaluation: ( + flagKey: string, + defaultValue: boolean, + context: EvaluationContext, + evalLogger: Logger, + ): ResolutionDetails => { + logger = evalLogger; // Capture the logger for async operations. + const cacheKey = JSON.stringify({ flagKey, context }); + + // Fetch in the background. + fetchFlagAndUpdateCache(flagKey, defaultValue, context, cacheKey, client, cache, emitter, logger); + + // Immediately return a cached value if available. + if (cache.has(cacheKey)) { + // The .get() method can return undefined, so we handle that case. + return cache.get(cacheKey) as ResolutionDetails; + } + + // Return a STALE value while the fetch is in progress. + return { + value: defaultValue, + reason: StandardResolutionReasons.STALE, + }; + }, + + // The other evaluation methods simply return an error. + resolveStringEvaluation: (flagKey: string, defaultValue: string): ResolutionDetails => ({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'RocketFlag: String flags are not yet supported.', + }), + + resolveNumberEvaluation: (flagKey: string, defaultValue: number): ResolutionDetails => ({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'RocketFlag: Number flags are not yet supported.', + }), + + resolveObjectEvaluation: (flagKey: string, defaultValue: U): ResolutionDetails => ({ + value: defaultValue, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + errorMessage: 'RocketFlag: Object flags are not yet supported.', + }), + }; + + // The OpenFeature SDK expects the provider itself to be an event emitter. + // We merge the EventEmitter instance with our provider logic. + return Object.assign(emitter, providerLogic); +} diff --git a/libs/providers/rocketflag/tsconfig.json b/libs/providers/rocketflag/tsconfig.json new file mode 100644 index 000000000..b2db732c1 --- /dev/null +++ b/libs/providers/rocketflag/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ES6", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "noPropertyAccessFromIndexSignature": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/providers/rocketflag/tsconfig.lib.json b/libs/providers/rocketflag/tsconfig.lib.json new file mode 100644 index 000000000..6f3c503a2 --- /dev/null +++ b/libs/providers/rocketflag/tsconfig.lib.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "declaration": true, + "types": ["node"] + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/libs/providers/rocketflag/tsconfig.spec.json b/libs/providers/rocketflag/tsconfig.spec.json new file mode 100644 index 000000000..a5992c7ff --- /dev/null +++ b/libs/providers/rocketflag/tsconfig.spec.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../dist/out-tsc", + "module": "commonjs", + "moduleResolution": "node10", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/nx.json b/nx.json index 63bede0e1..083d999d7 100644 --- a/nx.json +++ b/nx.json @@ -43,5 +43,10 @@ "production": ["default"] }, "useInferencePlugins": false, - "defaultBase": "main" + "defaultBase": "main", + "release": { + "version": { + "preVersionCommand": "npx nx run-many -t build" + } + } } diff --git a/release-please-config.json b/release-please-config.json index b807055d1..0b0d48ac5 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -162,6 +162,13 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default" + }, + "libs/providers/rocketflag": { + "release-type": "node", + "prerelease": false, + "bump-minor-pre-major": true, + "bump-patch-for-minor-pre-major": true, + "versioning": "default" } }, "changelog-sections": [ diff --git a/tsconfig.base.json b/tsconfig.base.json index 1788b25fb..5be87790b 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -38,6 +38,7 @@ "@openfeature/ofrep-core": ["libs/shared/ofrep-core/src/index.ts"], "@openfeature/ofrep-provider": ["libs/providers/ofrep/src/index.ts"], "@openfeature/ofrep-web-provider": ["libs/providers/ofrep-web/src/index.ts"], + "@openfeature/rocketflag-provider": ["libs/providers/rocketflag/src/index.ts"], "@openfeature/unleash-web-provider": ["libs/providers/unleash-web/src/index.ts"] } },