diff --git a/packages/adapter-flagsmith/.eslintrc.cjs b/packages/adapter-flagsmith/.eslintrc.cjs new file mode 100644 index 00000000..552408b2 --- /dev/null +++ b/packages/adapter-flagsmith/.eslintrc.cjs @@ -0,0 +1,4 @@ +module.exports = { + root: true, + extends: [require.resolve('@pyra/eslint-config/components')], +}; diff --git a/packages/adapter-flagsmith/.gitignore b/packages/adapter-flagsmith/.gitignore new file mode 100644 index 00000000..c8a73361 --- /dev/null +++ b/packages/adapter-flagsmith/.gitignore @@ -0,0 +1,2 @@ +.vercel +.env*.local diff --git a/packages/adapter-flagsmith/CHANGELOG.md b/packages/adapter-flagsmith/CHANGELOG.md new file mode 100644 index 00000000..68c019ae --- /dev/null +++ b/packages/adapter-flagsmith/CHANGELOG.md @@ -0,0 +1,3 @@ +# @flags-sdk/flagsmith + +## 0.1.0 diff --git a/packages/adapter-flagsmith/README.md b/packages/adapter-flagsmith/README.md new file mode 100644 index 00000000..36676b39 --- /dev/null +++ b/packages/adapter-flagsmith/README.md @@ -0,0 +1,64 @@ +# Flags SDK - Flagsmith Provider + +A provider adapter for the Flags SDK that integrates with [Flagsmith](https://flagsmith.com/), allowing you to use Flagsmith's feature flags and remote configuration in your application. + +## Installation + +```bash +npm install @flags-sdk/flagsmith +``` + +## Usage + +An Environment ID must be provided either using `FLAGSMITH_ENVIRONMENT_ID` environment variable or setting `environmentID` property in the initialization parameters + +```typescript +import { flagsmithAdapter } from '@flags-sdk/flagsmith'; +import { flag } from 'flags'; + +// Lazy Mode +const myFeatureFlag = flag({ + key: 'my-feature', + adapter: flagsmithAdapter.getFeature(), +}); + +// Custom initialization +const myFeatureFlag = flag({ + key: 'my-feature', + adapter: flagsmithAdapter.getFeature({ + key: 'other-feature', + api: 'https://custom-api.com', + environmentID: 'ABC', + }), +}); +``` + +## Configuration + +The adapter accepts all standard Flagsmith configuration options: + +```typescript +interface IInitConfig { + environmentID: string; + api?: string; + cache?: { + enabled?: boolean; + ttl?: number; + }; + enableAnalytics?: boolean; + enableLogs?: boolean; + // ... see Flagsmith documentation for more options +} +``` + +## Features + +- Seamless integration with Flagsmith's feature flag system +- Type-safe flag definitions +- Automatic initialization of the Flagsmith client +- Support for all Flagsmith configuration options +- Proper handling of default values + +## License + +MIT diff --git a/packages/adapter-flagsmith/package.json b/packages/adapter-flagsmith/package.json new file mode 100644 index 00000000..4f47d7e8 --- /dev/null +++ b/packages/adapter-flagsmith/package.json @@ -0,0 +1,70 @@ +{ + "name": "@flags-sdk/flagsmith", + "version": "0.1.0", + "description": "Flagsmith provider for the Flags SDK", + "keywords": [ + "flags-sdk", + "flagsmith-nodejs", + "vercel", + "feature flags", + "flags" + ], + "homepage": "https://flags-sdk.dev", + "bugs": { + "url": "https://github.com/vercel/flags/issues" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/vercel/flags.git" + }, + "license": "MIT", + "author": "", + "sideEffects": false, + "type": "module", + "exports": { + ".": { + "import": "./dist/index.js", + "require": "./dist/index.cjs" + } + }, + "main": "./dist/index.js", + "typesVersions": { + "*": { + ".": [ + "dist/*.d.ts", + "dist/*.d.cts" + ] + } + }, + "files": [ + "dist", + "CHANGELOG.md" + ], + "scripts": { + "build": "rimraf dist && tsup", + "dev": "tsup --watch --clean=false", + "eslint": "eslint-runner", + "eslint:fix": "eslint-runner --fix", + "test": "vitest --run", + "test:watch": "vitest", + "type-check": "tsc --noEmit" + }, + "dependencies": { + "flagsmith": "^9.0.5" + }, + "devDependencies": { + "@types/node": "20.11.17", + "eslint-config-custom": "workspace:*", + "flags": "workspace:*", + "msw": "2.6.4", + "rimraf": "6.0.1", + "tsconfig": "workspace:*", + "tsup": "8.0.1", + "typescript": "5.6.3", + "vite": "5.1.1", + "vitest": "1.4.0" + }, + "publishConfig": { + "access": "public" + } +} diff --git a/packages/adapter-flagsmith/src/index.test.ts b/packages/adapter-flagsmith/src/index.test.ts new file mode 100644 index 00000000..627ccb20 --- /dev/null +++ b/packages/adapter-flagsmith/src/index.test.ts @@ -0,0 +1,210 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { flagsmithAdapter } from '.'; +import flagsmith, { IFlagsmithFeature, IState, IIdentity } from 'flagsmith'; + +// Mock the flagsmith module +vi.mock('flagsmith', () => ({ + default: { + init: vi.fn(), + getState: vi.fn(), + identify: vi.fn(), + initialised: false, + }, +})); + +describe('Flagsmith Adapter', () => { + const mockHeaders = {} as any; + const mockCookies = {} as any; + const mockEnvironmentId = 'test-env-id'; + + beforeEach(() => { + vi.resetAllMocks(); + }); + + afterEach(() => { + vi.mocked(flagsmith.init).mockClear(); + vi.clearAllMocks(); + }); + + describe('booleanValue', () => { + it('should initialize the adapter', async () => { + const adapter = flagsmithAdapter.booleanValue(); + expect(adapter).toBeDefined(); + expect(adapter.decide).toBeDefined(); + }); + + it('should return flag enabled state for boolean values', async () => { + const adapter = flagsmithAdapter.booleanValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: true, + value: 'some-value', + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'test-flag', + defaultValue: false, + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(flagsmith.init).toHaveBeenCalledWith({ + fetch: expect.any(Function), + environmentID: mockEnvironmentId, + }); + expect(value).toBe(true); + }); + + it('should return default value when flag is not found', async () => { + const adapter = flagsmithAdapter.booleanValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: {}, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'non-existent-flag', + defaultValue: false, + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(value).toBe(false); + }); + }); + + describe('stringValue', () => { + it('should return string value when flag is enabled', async () => { + const adapter = flagsmithAdapter.stringValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: true, + value: 'test-value', + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'test-flag', + defaultValue: 'default', + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(value).toBe('test-value'); + }); + + it('should return default value when flag is disabled', async () => { + const adapter = flagsmithAdapter.stringValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: false, + value: 'test-value', + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'test-flag', + defaultValue: 'default', + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(value).toBe('default'); + }); + }); + + describe('numberValue', () => { + it('should return number value when flag is enabled', async () => { + const adapter = flagsmithAdapter.numberValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: true, + value: 42, + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'test-flag', + defaultValue: 0, + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(value).toBe(42); + }); + + it('should return default value when flag is disabled', async () => { + const adapter = flagsmithAdapter.numberValue(); + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: false, + value: 42, + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + const value = await adapter.decide({ + key: 'test-flag', + defaultValue: 0, + entities: undefined, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(value).toBe(0); + }); + }); + + describe('identity handling', () => { + it('should identify user when entities are provided', async () => { + const adapter = flagsmithAdapter.booleanValue(); + const identity: IIdentity = 'test-id'; + + vi.mocked(flagsmith.getState).mockReturnValue({ + flags: { + 'test-flag': { + enabled: true, + value: 'test-value', + }, + }, + api: 'https://api.flagsmith.com/api/v1/', + } as IState); + + await adapter.decide({ + key: 'test-flag', + defaultValue: false, + entities: identity, + headers: mockHeaders, + cookies: mockCookies, + }); + + expect(flagsmith.identify).toHaveBeenCalledWith(identity); + }); + }); +}); diff --git a/packages/adapter-flagsmith/src/index.ts b/packages/adapter-flagsmith/src/index.ts new file mode 100644 index 00000000..fbe0106d --- /dev/null +++ b/packages/adapter-flagsmith/src/index.ts @@ -0,0 +1,116 @@ +import type { Adapter } from 'flags'; +import flagsmith from 'flagsmith'; +import { IIdentity, IInitConfig, IFlagsmithFeature } from 'flagsmith/types'; + +export type { IIdentity } from 'flagsmith/types'; +export { getProviderData } from './provider'; + +let defaultFlagsmithAdapter: AdapterResponse | undefined; + +export type FlagsmithValue = IFlagsmithFeature['value']; + +export type EntitiesType = IIdentity; + +export type AdapterResponse = { + booleanValue: () => Adapter; + stringValue: () => Adapter; + numberValue: () => Adapter; +}; + +function createFlagsmithAdapter(params: IInitConfig): AdapterResponse { + async function initialize() { + await flagsmith.init({ fetch: globalThis.fetch, ...params }); + } + + function booleanValue(): Adapter { + return { + async decide({ + key, + defaultValue, + entities: identity, + }): Promise { + await initialize(); + + if (identity) { + await flagsmith.identify(identity); + } + const state = flagsmith.getState(); + const flagState = state.flags?.[key]; + + if (!flagState) { + return defaultValue as boolean; + } + + return flagState.enabled; + }, + }; + } + + function stringValue(): Adapter { + return { + async decide({ key, defaultValue, entities: identity }): Promise { + await initialize(); + + if (identity) { + await flagsmith.identify(identity); + } + const state = flagsmith.getState(); + const flagState = state.flags?.[key]; + + if (!flagState || !flagState.enabled) { + return defaultValue as string; + } + + return flagState.value as string; + }, + }; + } + + function numberValue(): Adapter { + return { + async decide({ key, defaultValue, entities: identity }): Promise { + await initialize(); + + if (identity) { + await flagsmith.identify(identity); + } + const state = flagsmith.getState(); + const flagState = state.flags?.[key]; + + if (!flagState || !flagState.enabled) { + return defaultValue as number; + } + + return flagState.value as number; + }, + }; + } + + return { + booleanValue, + stringValue, + numberValue, + }; +} + +function assertEnv(name: string): string { + const value = process.env[name]; + if (!value) { + throw new Error(`Flagsmith Adapter: Missing ${name} environment variable`); + } + return value; +} + +export const getOrCreateDefaultFlagsmithAdapter = () => { + if (!defaultFlagsmithAdapter) { + const environmentID = assertEnv('FLAGSMITH_ENVIRONMENT_ID'); + defaultFlagsmithAdapter = createFlagsmithAdapter({ + environmentID, + }); + } + return defaultFlagsmithAdapter; +}; + +// Lazy default adapter +export const flagsmithAdapter: AdapterResponse = + getOrCreateDefaultFlagsmithAdapter(); diff --git a/packages/adapter-flagsmith/src/provider/index.ts b/packages/adapter-flagsmith/src/provider/index.ts new file mode 100644 index 00000000..f9706e4e --- /dev/null +++ b/packages/adapter-flagsmith/src/provider/index.ts @@ -0,0 +1,99 @@ +import type { FlagDefinitionType, ProviderData } from 'flags'; + +type FlagsmithApiData = { + id: number; + feature: { + id: number; + name: string; + created_date: string; + description: string; + initial_value: string; + default_enabled: boolean; + type: string; + }; + feature_state_value: string; + environment: number; + identity: number; + feature_segment: number; + enabled: boolean; +}[]; + +export async function getProviderData(options: { + environmentKey: string; + projectID: string; +}): Promise { + const hints: Exclude = []; + + if (!options.environmentKey) { + hints.push({ + key: 'flagsmith/missing-environment-id', + text: 'Missing Flagsmith Environment ID', + }); + } + + if (!options.projectID) { + hints.push({ + key: 'flagsmith/missing-project-id', + text: 'Missing Flagsmith Project ID', + }); + } + + if (hints.length > 0) return { definitions: {}, hints }; + + try { + const res = await fetch(`https://api.flagsmith.com/api/v1/flags/`, { + method: 'GET', + headers: { + 'X-Environment-Key': options.environmentKey, + }, + // @ts-expect-error used by some Next.js versions + cache: 'no-store', + }); + + if (res.status !== 200) { + return { + definitions: {}, + hints: [ + { + key: `flagsmith/response-not-ok/${options.environmentKey}`, + text: `Failed to fetch Flagsmith (Received ${res.status} response)`, + }, + ], + }; + } + + const data = (await res.json()) as FlagsmithApiData; + const definitions = data?.reduce>( + (acc, flag) => { + acc[flag.feature.name] = { + origin: `https://app.flagsmith.com/project/${options.projectID}/environment/${options.environmentKey}/features/?feature=${flag?.id}`, + description: flag.feature.description, + createdAt: new Date(flag.feature.created_date).getTime(), + options: [ + { + value: flag.feature_state_value, + label: flag.feature_state_value, + }, + ], + }; + return acc; + }, + {}, + ); + + return { + definitions, + hints: [], + }; + } catch (error) { + return { + definitions: {}, + hints: [ + { + key: 'flagsmith/response-not-ok', + text: `Failed to fetch Flagsmith`, + }, + ], + }; + } +} diff --git a/packages/adapter-flagsmith/tsconfig.json b/packages/adapter-flagsmith/tsconfig.json new file mode 100644 index 00000000..2b2de3db --- /dev/null +++ b/packages/adapter-flagsmith/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "tsconfig/base.json", + "include": ["src"], + "compilerOptions": { + "lib": ["esnext"], + "resolveJsonModule": true, + "target": "ES2020" + } +} diff --git a/packages/adapter-flagsmith/tsup.config.js b/packages/adapter-flagsmith/tsup.config.js new file mode 100644 index 00000000..4cec4eb9 --- /dev/null +++ b/packages/adapter-flagsmith/tsup.config.js @@ -0,0 +1,14 @@ +import { defineConfig } from 'tsup'; + +// eslint-disable-next-line import/no-default-export -- [@vercel/style-guide@5 migration] +export default defineConfig({ + entry: ['src/index.ts'], + format: ['esm', 'cjs'], + splitting: true, + sourcemap: true, + minify: false, + clean: false, + skipNodeModulesBundle: true, + dts: true, + external: ['node_modules'], +}); diff --git a/packages/adapter-flagsmith/vitest.config.ts b/packages/adapter-flagsmith/vitest.config.ts new file mode 100644 index 00000000..4af8bf98 --- /dev/null +++ b/packages/adapter-flagsmith/vitest.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from 'vitest/config'; + +export default defineConfig({ + plugins: [], + test: { environment: 'node' }, +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index dcf88b8b..ab5e8958 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -350,6 +350,43 @@ importers: specifier: 1.4.0 version: 1.4.0(@types/node@20.11.17) + packages/adapter-flagsmith: + dependencies: + flagsmith: + specifier: ^9.0.5 + version: 9.0.5 + devDependencies: + '@types/node': + specifier: 20.11.17 + version: 20.11.17 + eslint-config-custom: + specifier: workspace:* + version: link:../../tooling/eslint-config-custom + flags: + specifier: workspace:* + version: link:../flags + msw: + specifier: 2.6.4 + version: 2.6.4(@types/node@20.11.17)(typescript@5.6.3) + rimraf: + specifier: 6.0.1 + version: 6.0.1 + tsconfig: + specifier: workspace:* + version: link:../../tooling/tsconfig + tsup: + specifier: 8.0.1 + version: 8.0.1(typescript@5.6.3) + typescript: + specifier: 5.6.3 + version: 5.6.3 + vite: + specifier: 5.1.1 + version: 5.1.1(@types/node@20.11.17) + vitest: + specifier: 1.4.0 + version: 1.4.0(@types/node@20.11.17) + packages/adapter-happykit: dependencies: '@happykit/flags': @@ -8503,6 +8540,10 @@ packages: pkg-dir: 4.2.0 dev: true + /flagsmith@9.0.5: + resolution: {integrity: sha512-bmAqGX+LUvToIl136eWKFmbVcVIpZ4H9mk2gDhz8YEKvF5HoM6Sj2WgtRei7pPJy4bXTJdfT7BLebu2v9USc4w==} + dev: false + /flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0}