diff --git a/.github/component_owners.yml b/.github/component_owners.yml index 81b8a5100..3b91c59ab 100644 --- a/.github/component_owners.yml +++ b/.github/component_owners.yml @@ -16,6 +16,8 @@ components: - toddbaert libs/providers/go-feature-flag: - thomaspoignant + libs/providers/go-feature-flag-web: + - thomaspoignant libs/providers/in-memory: - moredip - beeme1mr diff --git a/.release-please-manifest.json b/.release-please-manifest.json index 92d71a9ed..54926fb98 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -6,5 +6,6 @@ "libs/providers/env-var": "0.1.1", "libs/providers/in-memory": "0.2.0", "libs/providers/config-cat": "0.3.0", - "libs/providers/launchdarkly-client": "0.1.2" + "libs/providers/launchdarkly-client": "0.1.2", + "libs/providers/go-feature-flag-web": "0.1.0" } diff --git a/libs/providers/go-feature-flag-web/.eslintrc.json b/libs/providers/go-feature-flag-web/.eslintrc.json new file mode 100644 index 000000000..3456be9b9 --- /dev/null +++ b/libs/providers/go-feature-flag-web/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/providers/go-feature-flag-web/README.md b/libs/providers/go-feature-flag-web/README.md new file mode 100644 index 000000000..39339776a --- /dev/null +++ b/libs/providers/go-feature-flag-web/README.md @@ -0,0 +1,82 @@ +# go-feature-flag-web Provider for OpenFeature +## About this provider +[GO Feature Flag](https://gofeatureflag.org) provider allows you to connect to your GO Feature Flag instance with the `@openfeature/web-sdk`. + +The main difference between this provider and [`@openfeature/go-feature-flag-provider`](https://www.npmjs.com/package/@openfeature/go-feature-flag-provider) is that it uses a **static evaluation context**. +This provider is more sustainable for client-side implementation. + +If you want to know more about this pattern, I encourage you to read this [blog post](https://openfeature.dev/blog/catering-to-the-client-side/). + +## What is GO Feature Flag? +GO Feature Flag is a simple, complete and lightweight self-hosted feature flag solution 100% Open Source. +Our focus is to avoid any complex infrastructure work to use GO Feature Flag. + +This is a complete feature flagging solution with the possibility to target only a group of users, use any types of flags, store your configuration in various location and advanced rollout functionality. You can also collect usage data of your flags and be notified of configuration changes. + +## Install the provider + +```shell +npm install @openfeature/go-feature-flag-web-provider @openfeature/web-sdk +``` + +## How to use the provider? +```typescript +const evaluationCtx: EvaluationContext = { + targetingKey: 'user-key', + email: 'john.doe@gofeatureflag.org', + name: 'John Doe', +}; + +const goFeatureFlagWebProvider = new GoFeatureFlagWebProvider({ + endpoint: endpoint, + // ... +}, logger); + +await OpenFeature.setContext(evaluationCtx); // Set the static context for OpenFeature +OpenFeature.setProvider(goFeatureFlagWebProvider); // Attach the provider to OpenFeature +const client = await OpenFeature.getClient(); + +// You can now use the client to use your flags +if(client.getBooleanValue('my-new-feature', false)){ + //... +} + +// You can add handlers to know what happen in the provider +client.addHandler(ProviderEvents.Ready, () => { ... }); +client.addHandler(ProviderEvents.Error, () => { //... }); +client.addHandler(ProviderEvents.Stale, () => { //... }); +client.addHandler(ProviderEvents.ConfigurationChanged, () => { //... }); +``` + +### Available options +| Option name | Type | Default | Description | +|-------------------------------|--------|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| endpoint | string | | endpoint is the URL where your GO Feature Flag server is located. | +| apiTimeout | number | 0 = no timeout | (optional) timeout is the time in millisecond we wait for an answer from the server. | +| apiKey | string | | (optional) If GO Feature Flag is configured to authenticate the requests, you should provide an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. | +| websocketRetryInitialDelay | number | 100 | (optional) initial delay in millisecond to wait before retrying to connect the websocket | +| websocketRetryDelayMultiplier | number | 2 | (optional) multiplier of websocketRetryInitialDelay after each failure _(example: 1st connection retry will be after 100ms, second after 200ms, third after 400ms ...)_ | +| websocketMaxRetries | number | 10 | (optional) maximum number of retries before considering the websocket unreachable | + +### Reconnection +If the connection to the GO Feature Flag instance fails, the provider will attempt to reconnect with an exponential back-off. +The `websocketMaxRetries` can be specified to customize reconnect behavior. + +### Event streaming +The `GoFeatureFlagWebProvider` receives events from GO Feature Flag with changes. +Combined with the event API in the web SDK, this allows for subscription to flag value changes in clients. + +```typescript +client.addHandler(ProviderEvents.ConfigurationChanged, (ctx: EventDetails) => { + // do something when the configuration has changed. + // ctx.flagsChanged contains the list of changed flags. +}); +``` + +## Contribute + +### Building +Run `nx package providers-go-feature-flag-web` to build the library. + +### Running unit tests +Run `nx test providers-go-feature-flag-web` to execute the unit tests via [Jest](https://jestjs.io). diff --git a/libs/providers/go-feature-flag-web/babel.config.json b/libs/providers/go-feature-flag-web/babel.config.json new file mode 100644 index 000000000..d7bf474d1 --- /dev/null +++ b/libs/providers/go-feature-flag-web/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": [["minify", { "builtIns": false }]] +} diff --git a/libs/providers/go-feature-flag-web/jest.config.ts b/libs/providers/go-feature-flag-web/jest.config.ts new file mode 100644 index 000000000..9fa5e4b64 --- /dev/null +++ b/libs/providers/go-feature-flag-web/jest.config.ts @@ -0,0 +1,16 @@ +/* eslint-disable */ +export default { + displayName: 'providers-go-feature-flag-web', + preset: '../../../jest.preset.js', + globals: { + 'ts-jest': { + tsconfig: '/tsconfig.spec.json', + }, + }, + transform: { + '^.+\\.[tj]s$': 'ts-jest', + }, + testEnvironment: 'jsdom', + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/providers/go-feature-flag-web', +}; diff --git a/libs/providers/go-feature-flag-web/package-lock.json b/libs/providers/go-feature-flag-web/package-lock.json new file mode 100644 index 000000000..6609d056d --- /dev/null +++ b/libs/providers/go-feature-flag-web/package-lock.json @@ -0,0 +1,21 @@ +{ + "name": "@openfeature/go-feature-flag-web-provider", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "@openfeature/go-feature-flag-web-provider", + "version": "0.0.1", + "peerDependencies": { + "@openfeature/web-sdk": "*" + } + }, + "node_modules/@openfeature/web-sdk": { + "version": "0.3.7-experimental", + "resolved": "https://registry.npmjs.org/@openfeature/web-sdk/-/web-sdk-0.3.7-experimental.tgz", + "integrity": "sha512-FK9PTy+Wsw5OmCJlCD6wZsUZlnHTCycMHOU+2RNQVarct+c2l7pkz3XwKJUBjY3BHyOcW3qJEf4ojpO1fU3eJA==", + "peer": true + } + } +} diff --git a/libs/providers/go-feature-flag-web/package.json b/libs/providers/go-feature-flag-web/package.json new file mode 100644 index 000000000..9ec75cd95 --- /dev/null +++ b/libs/providers/go-feature-flag-web/package.json @@ -0,0 +1,12 @@ +{ + "name": "@openfeature/go-feature-flag-web-provider", + "version": "0.0.1", + "type": "commonjs", + "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" + }, + "peerDependencies": { + "@openfeature/web-sdk": "^0.4.0" + } +} diff --git a/libs/providers/go-feature-flag-web/project.json b/libs/providers/go-feature-flag-web/project.json new file mode 100644 index 000000000..0e7ed941d --- /dev/null +++ b/libs/providers/go-feature-flag-web/project.json @@ -0,0 +1,76 @@ +{ + "name": "providers-go-feature-flag-web", + "$schema": "../../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/providers/go-feature-flag-web/src", + "projectType": "library", + "targets": { + "publish": { + "executor": "nx:run-commands", + "options": { + "command": "npm run publish-if-not-exists", + "cwd": "dist/libs/providers/go-feature-flag-web" + }, + "dependsOn": [ + { + "projects": "self", + "target": "package" + } + ] + }, + "lint": { + "executor": "@nx/linter:eslint", + "outputs": ["{options.outputFile}"], + "options": { + "lintFilePatterns": ["libs/providers/go-feature-flag-web/**/*.ts"] + } + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": ["{workspaceRoot}/coverage/{projectRoot}"], + "options": { + "jestConfig": "libs/providers/go-feature-flag-web/jest.config.ts", + "passWithNoTests": true + }, + "configurations": { + "ci": { + "ci": true, + "codeCoverage": true + } + } + }, + "package": { + "executor": "@nx/rollup:rollup", + "outputs": ["{options.outputPath}"], + "options": { + "project": "libs/providers/go-feature-flag-web/package.json", + "outputPath": "dist/libs/providers/go-feature-flag-web", + "entryFile": "libs/providers/go-feature-flag-web/src/index.ts", + "tsConfig": "libs/providers/go-feature-flag-web/tsconfig.lib.json", + "buildableProjectDepsInPackageJsonType": "dependencies", + "compiler": "tsc", + "generateExportsField": true, + "umdName": "go-feature-flag-web", + "external": "all", + "format": ["cjs", "esm"], + "assets": [ + { + "glob": "package.json", + "input": "./assets", + "output": "./src/" + }, + { + "glob": "LICENSE", + "input": "./", + "output": "./" + }, + { + "glob": "README.md", + "input": "./libs/providers/go-feature-flag-web", + "output": "./" + } + ] + } + } + }, + "tags": [] +} diff --git a/libs/providers/go-feature-flag-web/src/index.ts b/libs/providers/go-feature-flag-web/src/index.ts new file mode 100644 index 000000000..ccaa8d866 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/index.ts @@ -0,0 +1 @@ +export * from './lib/go-feature-flag-web-provider'; diff --git a/libs/providers/go-feature-flag-web/src/lib/context-transfomer.spec.ts b/libs/providers/go-feature-flag-web/src/lib/context-transfomer.spec.ts new file mode 100644 index 000000000..04c7627f9 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/context-transfomer.spec.ts @@ -0,0 +1,63 @@ +import {EvaluationContext} from '@openfeature/js-sdk'; +import {GoFeatureFlagEvaluationContext} from './model'; +import {transformContext} from './context-transformer'; +import {TargetingKeyMissingError} from "@openfeature/web-sdk"; + +describe('contextTransformer', () => { + it('should use the targetingKey as user key', () => { + const got = transformContext({ + targetingKey: 'user-key', + } as EvaluationContext); + const want: GoFeatureFlagEvaluationContext = { + key: 'user-key', + custom: {}, + }; + expect(got).toEqual(want); + }); + + it('should specify the anonymous field base on attributes', () => { + const got = transformContext({ + targetingKey: 'user-key', + anonymous: true, + } as EvaluationContext); + const want: GoFeatureFlagEvaluationContext = { + key: 'user-key', + custom: { + anonymous: true, + }, + }; + expect(got).toEqual(want); + }); + + it('should hash the context as key if no targetingKey provided', () => { + expect(() => { + transformContext({ + anonymous: true, + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@gofeatureflag.org', + } as EvaluationContext); + }).toThrow(TargetingKeyMissingError); + }); + + it('should fill custom fields if extra field are present', () => { + const got = transformContext({ + targetingKey: 'user-key', + anonymous: true, + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@gofeatureflag.org', + } as EvaluationContext); + + const want: GoFeatureFlagEvaluationContext = { + key: 'user-key', + custom: { + firstname: 'John', + lastname: 'Doe', + email: 'john.doe@gofeatureflag.org', + anonymous: true, + }, + }; + expect(got).toEqual(want); + }); +}); diff --git a/libs/providers/go-feature-flag-web/src/lib/context-transformer.ts b/libs/providers/go-feature-flag-web/src/lib/context-transformer.ts new file mode 100644 index 000000000..b64daf25e --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/context-transformer.ts @@ -0,0 +1,21 @@ +import {EvaluationContext} from '@openfeature/js-sdk'; +import {GoFeatureFlagEvaluationContext} from './model'; +import {TargetingKeyMissingError} from "@openfeature/web-sdk"; + +/** + * transformContext takes the raw OpenFeature context returns a GoFeatureFlagEvaluationContext. + * @param context - the context used for flag evaluation. + * @returns {GoFeatureFlagEvaluationContext} the user against who we will evaluate the flag. + */ +export function transformContext( + context: EvaluationContext +): GoFeatureFlagEvaluationContext { + const {targetingKey, ...attributes} = context; + if (targetingKey === undefined || targetingKey === null || targetingKey === '') { + throw new TargetingKeyMissingError(); + } + return { + key: targetingKey, + custom: attributes, + }; +} diff --git a/libs/providers/go-feature-flag-web/src/lib/fetch-error.ts b/libs/providers/go-feature-flag-web/src/lib/fetch-error.ts new file mode 100644 index 000000000..fdfb9a522 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/fetch-error.ts @@ -0,0 +1,12 @@ +/** + * FetchError is a wrapper around the HTTP error returned by + * the method fetch. + * It allows to throw an error with the status code. + */ +export class FetchError extends Error{ + status: number; + constructor(status:number) { + super(`Request failed with status code ${status}`); + this.status = status; + } +} diff --git a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts new file mode 100644 index 000000000..fdad8a377 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.spec.ts @@ -0,0 +1,403 @@ +import {GoFeatureFlagWebProvider} from './go-feature-flag-web-provider'; +import {EvaluationContext, OpenFeature, ProviderEvents, StandardResolutionReasons} from "@openfeature/web-sdk"; +import WS from "jest-websocket-mock"; +import TestLogger from "./test-logger"; +import {ErrorCode, EvaluationDetails, JsonValue} from "@openfeature/js-sdk"; +import {GOFeatureFlagWebsocketResponse} from "./model"; +import fetchMock from "fetch-mock-jest"; + +describe('GoFeatureFlagWebProvider', () => { + let websocketMockServer: WS; + const endpoint = 'http://localhost:1031/'; + const allFlagsEndpoint = `${endpoint}v1/allflags`; + const websocketEndpoint = 'ws://localhost:1031/ws/v1/flag/change'; + const defaultAllFlagResponse = { + "flags": { + "bool_flag": { + "value": true, + "timestamp": 1689020159, + "variationType": "True", + "trackEvents": true, + "reason": "DEFAULT", + "metadata": { + "description": "this is a test flag" + } + }, + "number_flag": { + "value": 123, + "timestamp": 1689020159, + "variationType": "True", + "trackEvents": true, + "reason": "DEFAULT", + "metadata": { + "description": "this is a test flag" + } + }, + "string_flag": { + "value": 'value-flag', + "timestamp": 1689020159, + "variationType": "True", + "trackEvents": true, + "reason": "DEFAULT", + "metadata": { + "description": "this is a test flag" + } + }, + "object_flag": { + "value": {id: '123'}, + "timestamp": 1689020159, + "variationType": "True", + "trackEvents": true, + "reason": "DEFAULT", + "metadata": { + "description": "this is a test flag" + } + } + }, + "valid": true + }; + const alternativeAllFlagResponse = { + "flags": { + "bool_flag": { + "value": false, + "timestamp": 1689020159, + "variationType": "NEW_VARIATION", + "trackEvents": false, + "errorCode": "", + "reason": "TARGETING_MATCH", + "metadata": { + "description": "this is a test flag" + } + } + }, + "valid": true + }; + let defaultProvider: GoFeatureFlagWebProvider; + let defaultContext: EvaluationContext; + const readyHandler = jest.fn(); + const errorHandler = jest.fn(); + const configurationChangedHandler = jest.fn(); + const staleHandler = jest.fn(); + const logger = new TestLogger(); + + beforeEach(async () => { + await WS.clean(); + await OpenFeature.close(); + fetchMock.mockClear(); + fetchMock.mockReset(); + await jest.resetAllMocks(); + websocketMockServer = new WS(websocketEndpoint, {jsonProtocol: true}); + fetchMock.post(allFlagsEndpoint,defaultAllFlagResponse); + defaultProvider = new GoFeatureFlagWebProvider({ + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + }, logger); + defaultContext = {targetingKey: 'user-key'}; + }); + + afterEach(async () => { + await WS.clean(); + websocketMockServer.close() + await OpenFeature.close(); + await OpenFeature.clearHooks(); + fetchMock.mockClear(); + fetchMock.mockReset(); + await defaultProvider?.onClose(); + await jest.resetAllMocks(); + readyHandler.mockReset(); + errorHandler.mockReset(); + configurationChangedHandler.mockReset(); + staleHandler.mockReset(); + logger.reset(); + }); + + function newDefaultProvider(): GoFeatureFlagWebProvider { + return new GoFeatureFlagWebProvider({ + endpoint: endpoint, + apiTimeout: 1000, + maxRetries: 1, + }, logger); + } + + describe('provider metadata', () => { + it('should be and instance of GoFeatureFlagWebProvider', () => { + expect(defaultProvider).toBeInstanceOf(GoFeatureFlagWebProvider); + }); + }); + + describe('flag evaluation', () => { + /** + * TODO: reactivate this test when the issue "web-sdk: onContextChange not called for named provider" is solved.\ + * Issue link: https://github.com/open-feature/js-sdk/issues/488 + */ + it('should change evaluation value if context has changed', async () => { + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + + const got1 = client.getBooleanDetails('bool_flag', false); + fetchMock.post(allFlagsEndpoint, alternativeAllFlagResponse, {overwriteRoutes: true}); + await OpenFeature.setContext({targetingKey: "1234"}); + const got2 = client.getBooleanDetails('bool_flag', false); + + expect(got1.value).toEqual(defaultAllFlagResponse.flags.bool_flag.value); + expect(got1.variant).toEqual(defaultAllFlagResponse.flags.bool_flag.variationType); + expect(got1.reason).toEqual(defaultAllFlagResponse.flags.bool_flag.reason); + + expect(got2.value).toEqual(alternativeAllFlagResponse.flags.bool_flag.value); + expect(got2.variant).toEqual(alternativeAllFlagResponse.flags.bool_flag.variationType); + expect(got2.reason).toEqual(alternativeAllFlagResponse.flags.bool_flag.reason); + }); + + it('should return CACHED as a reason is websocket is not connected', async () => { + await OpenFeature.setContext(defaultContext); + const providerName = expect.getState().currentTestName || 'test'; + OpenFeature.setProvider(providerName, newDefaultProvider()); + const client = await OpenFeature.getClient(providerName); + await websocketMockServer.connected; + // Need to wait before using the mock + await new Promise((resolve) => setTimeout(resolve, 5)); + await websocketMockServer.close() + + const got = client.getBooleanDetails('bool_flag', false); + expect(got.reason).toEqual(StandardResolutionReasons.CACHED); + }); + + it('should emit an error if we have the wrong credentials', async () => { + fetchMock.post(allFlagsEndpoint,401, {overwriteRoutes: true}); + const providerName = expect.getState().currentTestName || 'test'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider(providerName, newDefaultProvider()); + const client = await OpenFeature.getClient(providerName); + client.addHandler(ProviderEvents.Error, errorHandler); + // wait the event to be triggered + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(errorHandler).toBeCalled() + expect(logger.inMemoryLogger['error'][0]) + .toEqual('GoFeatureFlagWebProvider: invalid token used to contact GO Feature Flag instance: Error: Request failed with status code 401'); + }); + + it('should emit an error if we receive a 404 from GO Feature Flag', async () => { + fetchMock.post(allFlagsEndpoint,404, {overwriteRoutes: true}); + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + client.addHandler(ProviderEvents.Ready, readyHandler); + client.addHandler(ProviderEvents.Error, errorHandler); + client.addHandler(ProviderEvents.Stale, staleHandler); + client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler); + // wait the event to be triggered + await new Promise((resolve) => setTimeout(resolve, 5)); + expect(errorHandler).toBeCalled() + expect(logger.inMemoryLogger['error'][0]) + .toEqual('GoFeatureFlagWebProvider: impossible to call go-feature-flag relay proxy Error: Request failed with status code 404'); + }); + + it('should get a valid boolean flag evaluation', async () => { + const flagKey = 'bool_flag'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getBooleanDetails(flagKey, false); + const want: EvaluationDetails = { + flagKey, + value: true, + variant: 'True', + flagMetadata: { + description: "this is a test flag" + }, + reason: StandardResolutionReasons.DEFAULT, + }; + expect(got).toEqual(want); + }); + + it('should get a valid string flag evaluation', async () => { + const flagKey = 'string_flag'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getStringDetails(flagKey, 'false'); + const want: EvaluationDetails = { + flagKey, + value: 'value-flag', + variant: 'True', + flagMetadata: { + description: "this is a test flag" + }, + reason: StandardResolutionReasons.DEFAULT, + }; + expect(got).toEqual(want); + }); + + it('should get a valid number flag evaluation', async () => { + const flagKey = 'number_flag'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getNumberDetails(flagKey, 456); + const want: EvaluationDetails = { + flagKey, + value: 123, + variant: 'True', + flagMetadata: { + description: "this is a test flag" + }, + reason: StandardResolutionReasons.DEFAULT, + }; + expect(got).toEqual(want); + }); + + it('should get a valid object flag evaluation', async () => { + const flagKey = 'object_flag'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getObjectDetails(flagKey, {error: true}); + const want: EvaluationDetails = { + flagKey, + value: {id: "123"}, + variant: 'True', + flagMetadata: { + description: "this is a test flag" + }, + reason: StandardResolutionReasons.DEFAULT, + }; + expect(got).toEqual(want); + }); + + it('should get an error if evaluate a boolean flag with a string function', async () => { + const flagKey = 'bool_flag'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getStringDetails(flagKey, 'false'); + const want: EvaluationDetails = { + flagKey, + value: "false", + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.TYPE_MISMATCH, + flagMetadata: {}, + errorMessage: "flag key bool_flag is not of type string", + }; + expect(got).toEqual(want); + }); + + it('should get an error if flag does not exists', async () => { + const flagKey = 'not-exist'; + await OpenFeature.setContext(defaultContext); + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + await websocketMockServer.connected + const got = client.getBooleanDetails(flagKey, false); + const want: EvaluationDetails = { + flagKey, + value: false, + reason: StandardResolutionReasons.ERROR, + errorCode: ErrorCode.FLAG_NOT_FOUND, + flagMetadata: {}, + errorMessage: "flag key not-exist not found in cache", + }; + expect(got).toEqual(want); + }); + }); + + describe('eventing', () => { + it('should call client handler with ProviderEvents.Ready when websocket is connected', async () => { + // await OpenFeature.setContext(defaultContext); // we deactivate this call because the context is already set, and we want to avoid calling contextChanged function + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + client.addHandler(ProviderEvents.Ready, readyHandler); + client.addHandler(ProviderEvents.Error, errorHandler); + client.addHandler(ProviderEvents.Stale, staleHandler); + client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler); + + // wait for the websocket to be connected to the provider. + await websocketMockServer.connected; + await new Promise((resolve) => setTimeout(resolve, 5)); + + expect(readyHandler).toBeCalled(); + expect(errorHandler).not.toBeCalled(); + expect(configurationChangedHandler).not.toBeCalled(); + expect(staleHandler).not.toBeCalled(); + }); + + it('should call client handler with ProviderEvents.ConfigurationChanged when websocket is sending update', async () => { + // await OpenFeature.setContext(defaultContext); // we deactivate this call because the context is already set, and we want to avoid calling contextChanged function + OpenFeature.setProvider('test-provider', defaultProvider); + const client = await OpenFeature.getClient('test-provider'); + + client.addHandler(ProviderEvents.Ready, readyHandler); + client.addHandler(ProviderEvents.Error, errorHandler); + client.addHandler(ProviderEvents.Stale, staleHandler); + client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler); + + // wait for the websocket to be connected to the provider. + await websocketMockServer.connected; + + // Need to wait before using the mock + await new Promise((resolve) => setTimeout(resolve, 5)); + websocketMockServer.send({ + added: { + "added-flag-1": {}, + "added-flag-2": {} + }, + updated: { + "updated-flag-1": {}, + "updated-flag-2": {}, + }, + deleted: { + "deleted-flag-1": {}, + "deleted-flag-2": {}, + } + } as GOFeatureFlagWebsocketResponse); + // waiting the call to the API to be successful + await new Promise((resolve) => setTimeout(resolve, 50)); + + expect(readyHandler).toBeCalled(); + expect(errorHandler).not.toBeCalled(); + expect(configurationChangedHandler).toBeCalled(); + expect(staleHandler).not.toBeCalled(); + expect(configurationChangedHandler.mock.calls[0][0]).toEqual({ + clientName: 'test-provider', + message: 'flag configuration have changed', + flagsChanged: ['deleted-flag-1', 'deleted-flag-2', 'updated-flag-1', 'updated-flag-2', 'added-flag-1', 'added-flag-2'] + }) + }); + + it('should call client handler with ProviderEvents.Stale when websocket is unreachable', async () => { + // await OpenFeature.setContext(defaultContext); // we deactivate this call because the context is already set, and we want to avoid calling contextChanged function + const provider = new GoFeatureFlagWebProvider({ + endpoint, + maxRetries: 1, + retryInitialDelay: 10, + }, logger); + OpenFeature.setProvider('test-provider', provider); + const client = await OpenFeature.getClient('test-provider'); + client.addHandler(ProviderEvents.Ready, readyHandler); + client.addHandler(ProviderEvents.Error, errorHandler); + client.addHandler(ProviderEvents.Stale, staleHandler); + client.addHandler(ProviderEvents.ConfigurationChanged, configurationChangedHandler); + + // wait for the websocket to be connected to the provider. + await websocketMockServer.connected; + + // Need to wait before using the mock + await new Promise((resolve) => setTimeout(resolve, 5)); + await websocketMockServer.close() + await new Promise((resolve) => setTimeout(resolve, 300)); + + expect(readyHandler).toBeCalled(); + expect(errorHandler).not.toBeCalled(); + expect(configurationChangedHandler).not.toBeCalled(); + expect(staleHandler).toBeCalled(); + }); + }) +}); diff --git a/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts new file mode 100644 index 000000000..315609c90 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/go-feature-flag-web-provider.ts @@ -0,0 +1,341 @@ +import { + EvaluationContext, + FlagNotFoundError, + FlagValue, + Logger, + OpenFeature, + OpenFeatureEventEmitter, + Provider, + ProviderEvents, + ProviderStatus, + ResolutionDetails, + StandardResolutionReasons, + TypeMismatchError, +} from "@openfeature/web-sdk"; +import { + FlagState, + GoFeatureFlagAllFlagRequest, + GOFeatureFlagAllFlagsResponse, + GoFeatureFlagWebProviderOptions, + GOFeatureFlagWebsocketResponse, +} from "./model"; +import {transformContext} from "./context-transformer"; +import {FetchError} from "./fetch-error"; + +export class GoFeatureFlagWebProvider implements Provider { + private readonly _websocketPath = "ws/v1/flag/change" + + metadata = { + name: GoFeatureFlagWebProvider.name, + }; + events = new OpenFeatureEventEmitter(); + + // logger is the Open Feature logger to use + private _logger?: Logger; + // endpoint of your go-feature-flag relay proxy instance + private readonly _endpoint: string; + // timeout in millisecond before we consider the http request as a failure + private readonly _apiTimeout: number; + // apiKey is the key used to identify your request in GO Feature Flag + private readonly _apiKey: string | undefined; + + // initial delay in millisecond to wait before retrying to connect + private readonly _retryInitialDelay; + // multiplier of _retryInitialDelay after each failure + private readonly _retryDelayMultiplier; + // maximum number of retries + private readonly _maxRetries; + // status of the provider + private _status: ProviderStatus = ProviderStatus.NOT_READY; + // _websocket is the reference to the websocket connection + private _websocket?: WebSocket; + // _flags is the in memory representation of all the flags. + private _flags: { [key: string]: ResolutionDetails } = {}; + + + constructor(options: GoFeatureFlagWebProviderOptions, logger?: Logger) { + this._logger = logger; + this._apiTimeout = options.apiTimeout || 0; // default is 0 = no timeout + this._endpoint = options.endpoint; + this._retryInitialDelay = options.retryInitialDelay || 100; + this._retryDelayMultiplier = options.retryDelayMultiplier || 2; + this._maxRetries = options.maxRetries || 10; + this._apiKey = options.apiKey; + } + + get status(): ProviderStatus { + return this._status; + } + + async initialize(context: EvaluationContext): Promise { + return Promise.all([this.fetchAll(context), this.connectWebsocket()]) + .then(() => { + this._status = ProviderStatus.READY; + this._logger?.debug(`${GoFeatureFlagWebProvider.name}: go-feature-flag provider initialized`); + }) + .catch((error) => { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: initialization failed, provider is on error, we will try to reconnect: ${error}`); + this._status = ProviderStatus.ERROR; + this.handleFetchErrors(error); + + // The initialization of the provider is in a failing state, we unblock the initialize method, + // and we launch the retry to fetch the data. + this.retryFetchAll(context); + this.reconnectWebsocket(); + }) + } + + /** + * connectWebsocket is starting the websocket and associate some handler + * to react if the state of the websocket change. + */ + async connectWebsocket(): Promise { + const wsURL = new URL(this._endpoint); + wsURL.pathname = + wsURL.pathname.endsWith('/') ? wsURL.pathname + this._websocketPath : wsURL.pathname + '/' + this._websocketPath; + wsURL.protocol = "ws" + + // adding API Key if GO Feature Flag use api keys. + if(this._apiKey){ + wsURL.searchParams.set('apiKey', this._apiKey); + } + + this._logger?.debug(`${GoFeatureFlagWebProvider.name}: Trying to connect the websocket at ${wsURL}`) + + this._websocket = new WebSocket(wsURL, ["ws", "http", "https"]); + await this.waitWebsocketFinalStatus(this._websocket); + + this._websocket.onopen = (event) => { + this._logger?.info(`${GoFeatureFlagWebProvider.name}: Websocket to go-feature-flag open: ${event}`); + }; + this._websocket.onmessage = async ({data}) => { + this._logger?.info(`${GoFeatureFlagWebProvider.name}: Change in your configuration flag`); + const t: GOFeatureFlagWebsocketResponse = JSON.parse(data); + const flagsChanged = this.extractFlagNamesFromWebsocket(t); + await this.retryFetchAll(OpenFeature.getContext(), flagsChanged); + } + this._websocket.onclose = async () => { + this._logger?.warn(`${GoFeatureFlagWebProvider.name}: Websocket closed, trying to reconnect`); + await this.reconnectWebsocket(); + }; + this._websocket.onerror = async (event: Event) => { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: Error while connecting the websocket: ${event}`); + await this.reconnectWebsocket(); + }; + } + + /** + * waitWebsocketFinalStatus is waiting synchronously for the websocket to be in a stable + * state (CLOSED or OPEN). + * @param socket - the websocket you are waiting for + */ + waitWebsocketFinalStatus(socket: WebSocket): Promise { + return new Promise((resolve) => { + const checkConnection = () => { + if (socket.readyState === WebSocket.OPEN || socket.readyState === WebSocket.CLOSED) { + return resolve(); + } + // Wait 5 milliseconds before checking again + setTimeout(checkConnection, 5); + }; + checkConnection(); + }); + } + + /** + * extract flag names from the websocket answer + */ + private extractFlagNamesFromWebsocket(wsResp: GOFeatureFlagWebsocketResponse): string[] { + let flags: string[] = []; + if (wsResp.deleted) { + flags = [...flags, ...Object.keys(wsResp.deleted)]; + } + if (wsResp.updated) { + flags = [...flags, ...Object.keys(wsResp.updated)]; + } + if (wsResp.added) { + flags = [...flags, ...Object.keys(wsResp.added)]; + } + return flags; + } + + /** + * reconnectWebsocket is using an exponential backoff pattern to try to restart the connection + * to the websocket. + */ + private async reconnectWebsocket() { + let delay = this._retryInitialDelay; + let attempt = 0; + while (attempt < this._maxRetries) { + attempt++; + await this.connectWebsocket() + if (this._websocket !== undefined && this._websocket.readyState === WebSocket.OPEN) { + return + } + + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= this._retryDelayMultiplier; + this._logger?.info(`${GoFeatureFlagWebProvider.name}: error while reconnecting the websocket, next try in ${delay} ms (${attempt}/${this._maxRetries}).`) + } + this.events.emit(ProviderEvents.Stale, { + message: 'impossible to get status from GO Feature Flag (websocket connection stopped)' + }); + } + + onClose(): Promise { + this._websocket?.close(1000, "Closing GO Feature Flag provider"); + return Promise.resolve(); + } + + async onContextChange(_: EvaluationContext, newContext: EvaluationContext): Promise { + this._logger?.debug(`${GoFeatureFlagWebProvider.name}: new context provided: ${newContext}`); + this.events.emit(ProviderEvents.Stale, {message: 'context has changed'}); + await this.retryFetchAll(newContext); + this.events.emit(ProviderEvents.Ready, {message: ''}); + } + + resolveNumberEvaluation(flagKey: string): ResolutionDetails { + return this.evaluate(flagKey, 'number') + } + + resolveObjectEvaluation(flagKey: string): ResolutionDetails { + return this.evaluate(flagKey, 'object') + } + + resolveStringEvaluation(flagKey: string): ResolutionDetails { + return this.evaluate(flagKey, 'string') + } + + resolveBooleanEvaluation(flagKey: string): ResolutionDetails { + return this.evaluate(flagKey, 'boolean') + } + + private evaluate(flagKey: string, type: string): ResolutionDetails { + const resolved = this._flags[flagKey]; + if (!resolved) { + throw new FlagNotFoundError(`flag key ${flagKey} not found in cache`); + } + + if (typeof resolved.value !== type) { + throw new TypeMismatchError(`flag key ${flagKey} is not of type ${type}`); + } + return { + variant: resolved.variant, + value: resolved.value as T, + flagMetadata: resolved.flagMetadata, + errorCode: resolved.errorCode, + errorMessage: resolved.errorMessage, + reason: this._websocket?.readyState !== WebSocket.OPEN ? StandardResolutionReasons.CACHED : resolved.reason, + }; + } + + private async retryFetchAll(ctx: EvaluationContext, flagsChanged: string[] = []) { + let delay = this._retryInitialDelay; + let attempt = 0; + while (attempt < this._maxRetries) { + attempt++; + try { + await this.fetchAll(ctx, flagsChanged); + this._status = ProviderStatus.READY; + return + } catch (err) { + this._status = ProviderStatus.ERROR; + this.handleFetchErrors(err) + await new Promise((resolve) => setTimeout(resolve, delay)); + delay *= this._retryDelayMultiplier; + this._logger?.info(`${GoFeatureFlagWebProvider.name}: Waiting ${delay} ms before trying to evaluate the flags (${attempt}/${this._maxRetries}).`) + } + } + } + + /** + * fetchAll is a function that is calling GO Feature Flag to bulk evaluate flags. + * It emits an event to notify when it is ready or on error. + * + * @param context - The static evaluation context + * @param flagsChanged - The list of flags update - default: [] + * @private + */ + private async fetchAll(context: EvaluationContext, flagsChanged: string[] = []) { + const endpointURL = new URL(this._endpoint); + const path = 'v1/allflags'; + endpointURL.pathname = endpointURL.pathname.endsWith('/') ? endpointURL.pathname + path : endpointURL.pathname + '/' + path; + + const request: GoFeatureFlagAllFlagRequest = {evaluationContext: transformContext(context)}; + const headers = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + }); + if(this._apiKey){ + headers.set('Authorization', `Bearer ${this._apiKey}`); + } + + const init: RequestInit = { + method: "POST", + headers: { + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + body: JSON.stringify(request) + }; + const response = await fetch(endpointURL.toString(), init); + + if(!response?.ok){ + throw new FetchError(response.status); + } + + const data = await response.json() as GOFeatureFlagAllFlagsResponse; + // In case we are in success + let flags = {}; + Object.keys(data.flags).forEach(currentValue => { + const resolved: FlagState = data.flags[currentValue]; + const resolutionDetails: ResolutionDetails = { + value: resolved.value, + variant: resolved.variationType, + errorCode: resolved.errorCode, + flagMetadata: resolved.metadata, + reason: resolved.reason + }; + flags = { + ...flags, + [currentValue]: resolutionDetails + }; + }); + const hasFlagsLoaded = this._flags !== undefined && Object.keys(this._flags).length !== 0 + this._flags = flags; + if (hasFlagsLoaded) { + this.events.emit(ProviderEvents.ConfigurationChanged, { + message: 'flag configuration have changed', + flagsChanged: flagsChanged, + }); + } + } + + /** + * handleFetchErrors is a function that take care of the errors that can be thrown + * inside the FetchAll method. + * + * @param error - The error thrown + * @private + */ + private handleFetchErrors(error: unknown) { + if (error instanceof FetchError) { + this.events.emit(ProviderEvents.Error, { + message: error.message, + }); + if (error.status == 401) { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: invalid token used to contact GO Feature Flag instance: ${error}`); + } else if (error.status === 404) { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: impossible to call go-feature-flag relay proxy ${error}`); + } else { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: unknown error while retrieving flags: ${error}`); + } + } else { + this._logger?.error(`${GoFeatureFlagWebProvider.name}: unknown error while retrieving flags: ${error}`); + this.events.emit(ProviderEvents.Error, { + message: 'unknown error while retrieving flags', + }); + } + } +} + diff --git a/libs/providers/go-feature-flag-web/src/lib/model.ts b/libs/providers/go-feature-flag-web/src/lib/model.ts new file mode 100644 index 000000000..48cf94324 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/model.ts @@ -0,0 +1,91 @@ +import { + ErrorCode, + EvaluationContextValue, +} from '@openfeature/js-sdk'; +import {FlagValue} from "@openfeature/web-sdk"; + +/** + * GoFeatureFlagEvaluationContext is the representation of a user for GO Feature Flag + * the key is used to do the repartition in GO Feature Flag this is the only + * mandatory field when calling the API. + */ +export interface GoFeatureFlagEvaluationContext { + key: string; + custom?: { + [key: string]: EvaluationContextValue; + }; +} + +/** + * GoFeatureFlagAllFlagRequest is the request format used to call the GO Feature Flag + * API to retrieve all the feature flags for this user. + */ +export interface GoFeatureFlagAllFlagRequest { + evaluationContext: GoFeatureFlagEvaluationContext; +} + + +/** + * GoFeatureFlagProviderOptions is the object containing all the provider options + * when initializing the open-feature provider. + */ +export interface GoFeatureFlagWebProviderOptions { + // endpoint is the URL where your GO Feature Flag server is located. + endpoint: string; + + // timeout is the time in millisecond we wait for an answer from the server. + apiTimeout?: number; + + // apiKey (optional) If the relay proxy is configured to authenticate the requests, you should provide + // an API Key to the provider. Please ask the administrator of the relay proxy to provide an API Key. + // (This feature is available only if you are using GO Feature Flag relay proxy v1.7.0 or above) + // Default: null + apiKey?: string; + + // initial delay in millisecond to wait before retrying to connect to GO Feature Flag (websocket and API) + // Default: 100 ms + retryInitialDelay?: number; + + // multiplier of retryInitialDelay after each failure + // (example: 1st connection retry will be after 100ms, second after 200ms, third after 400ms ...) + // Default: 2 + retryDelayMultiplier?: number; + + // maximum number of retries before considering GO Feature Flag is unreachable + // Default: 10 + maxRetries?: number; +} + + +/** + * FlagState is the object used to get the value return by GO Feature Flag. + */ +export interface FlagState { + failed: boolean; + trackEvents: boolean; + value: T; + variationType: string; + version?: string; + reason: string; + metadata: Record; + errorCode?: ErrorCode; + cacheable: boolean; +} + +/** + * GOFeatureFlagAllFlagsResponse is the object containing the results returned + * by GO Feature Flag. + */ +export interface GOFeatureFlagAllFlagsResponse { + valid: boolean + flags: Record> +} + +/** + * Format of the websocket event we can receive. + */ +export interface GOFeatureFlagWebsocketResponse { + deleted?: { [key: string]: any } + added?: { [key: string]: any } + updated?: { [key: string]: any } +} diff --git a/libs/providers/go-feature-flag-web/src/lib/test-logger.ts b/libs/providers/go-feature-flag-web/src/lib/test-logger.ts new file mode 100644 index 000000000..7798ecc59 --- /dev/null +++ b/libs/providers/go-feature-flag-web/src/lib/test-logger.ts @@ -0,0 +1,37 @@ +/** + * TestLogger is a logger build for testing purposes. + * This is not ready to be production ready, so please avoid using it. + */ +export default class TestLogger { + public inMemoryLogger: Record = { + error: [], + warn: [], + info: [], + debug: [], + }; + + error(...args: unknown[]): void { + this.inMemoryLogger['error'].push(args.join(' ')); + } + + warn(...args: unknown[]): void { + this.inMemoryLogger['warn'].push(args.join(' ')); + } + + info(...args: unknown[]): void { + this.inMemoryLogger['info'].push(args.join(' ')); + } + + debug(...args: unknown[]): void { + this.inMemoryLogger['debug'].push(args.join(' ')); + } + + reset() { + this.inMemoryLogger = { + error: [], + warn: [], + info: [], + debug: [], + }; + } +} diff --git a/libs/providers/go-feature-flag-web/tsconfig.json b/libs/providers/go-feature-flag-web/tsconfig.json new file mode 100644 index 000000000..38f0cb8de --- /dev/null +++ b/libs/providers/go-feature-flag-web/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "ES6", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": false, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/providers/go-feature-flag-web/tsconfig.lib.json b/libs/providers/go-feature-flag-web/tsconfig.lib.json new file mode 100644 index 000000000..677013480 --- /dev/null +++ b/libs/providers/go-feature-flag-web/tsconfig.lib.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "lib": ["ES2015", "DOM"], + "outDir": "../../../dist/out-tsc", + "declaration": true, + "types": [], + "allowSyntheticDefaultImports": true, + }, + "include": ["**/*.ts"], + "exclude": ["jest.config.ts", "**/*.spec.ts", "**/*.test.ts"] + +} diff --git a/libs/providers/go-feature-flag-web/tsconfig.spec.json b/libs/providers/go-feature-flag-web/tsconfig.spec.json new file mode 100644 index 000000000..b2ee74a6b --- /dev/null +++ b/libs/providers/go-feature-flag-web/tsconfig.spec.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": ["jest.config.ts", "src/**/*.test.ts", "src/**/*.spec.ts", "src/**/*.d.ts"] +} diff --git a/package-lock.json b/package-lock.json index 9f0485fea..96cde859a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -54,9 +54,11 @@ "babel-preset-minify": "0.5.2", "eslint": "~8.46.0", "eslint-config-prettier": "8.9.0", + "fetch-mock-jest": "^1.5.1", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-fetch-mock": "^3.0.3", + "jest-websocket-mock": "^2.4.0", "jsonc-eslint-parser": "^2.1.0", "nx": "16.5.5", "nx-cloud": "16.1.0", @@ -140,9 +142,9 @@ } }, "node_modules/@babel/core/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -230,9 +232,9 @@ } }, "node_modules/@babel/helper-compilation-targets/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -268,9 +270,9 @@ } }, "node_modules/@babel/helper-create-class-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -294,9 +296,9 @@ } }, "node_modules/@babel/helper-create-regexp-features-plugin/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -320,9 +322,9 @@ } }, "node_modules/@babel/helper-define-polyfill-provider/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -1632,9 +1634,9 @@ } }, "node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -1856,9 +1858,9 @@ } }, "node_modules/@babel/preset-env/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -2072,9 +2074,9 @@ "dev": true }, "node_modules/@eslint/eslintrc/node_modules/globals": { - "version": "13.19.0", - "resolved": "https://registry.npmjs.org/globals/-/globals-13.19.0.tgz", - "integrity": "sha512-dkQ957uSRWHw7CFXLUtUHQI3g3aWApYhfNR2O6jn/907riyTYKVBmxYVROkBcY614FSSeSJh7Xm7SrUWCxvJMQ==", + "version": "13.20.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-13.20.0.tgz", + "integrity": "sha512-Qg5QtVkCy/kv3FUSlu4ukeZDVf9ee0iXLAUYX13gbR17bnejFTzr4iS9bY7kwCf1NztRNm1t91fjOiyx4CSwPQ==", "dev": true, "dependencies": { "type-fest": "^0.20.2" @@ -2398,6 +2400,18 @@ } } }, + "node_modules/@jest/schemas": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/@jest/schemas/-/schemas-28.1.3.tgz", + "integrity": "sha512-/l/VWsdt/aBXgjshLWOFyFt3IVdYypu5y2Wn2rOO1un6nkqIn8SLXzgIMYXFyYsRWDyF5EthmKJMIdJvk08grg==", + "dev": true, + "dependencies": { + "@sinclair/typebox": "^0.24.1" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, "node_modules/@jest/source-map": { "version": "29.4.3", "resolved": "https://registry.npmjs.org/@jest/source-map/-/source-map-29.4.3.tgz", @@ -3430,6 +3444,12 @@ "integrity": "sha512-1fMXF3YP4pZZVozF8j/ZLfvnR8NSIljt56UhbZ5PeeDmmGHpgpdwQt7ITlGvYaQukCvuBRMLEiKiYC+oeIg4cg==", "dev": true }, + "node_modules/@sinclair/typebox": { + "version": "0.24.51", + "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.24.51.tgz", + "integrity": "sha512-1P1OROm/rdubP5aFDSZQILU0vrLCJ4fvHt6EoqHEM+2D/G5MK3bIaymUKLit8Js9gbns5UyJnkP/TZROLw4tUA==", + "dev": true + }, "node_modules/@sindresorhus/is": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-4.6.0.tgz", @@ -3598,33 +3618,17 @@ } } }, - "node_modules/@swc/core-linux-x64-gnu": { - "version": "1.3.62", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-gnu/-/core-linux-x64-gnu-1.3.62.tgz", - "integrity": "sha512-wnHJkt3ZBrax3SFnUHDcncG6mrSg9ZZjMhQV9Mc3JL1x1s1Gy9rGZCoBNnV/BUZWTemxIBcQbANRSDut/WO+9A==", - "cpu": [ - "x64" - ], - "dev": true, - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=10" - } - }, - "node_modules/@swc/core-linux-x64-musl": { + "node_modules/@swc/core-darwin-arm64": { "version": "1.3.62", - "resolved": "https://registry.npmjs.org/@swc/core-linux-x64-musl/-/core-linux-x64-musl-1.3.62.tgz", - "integrity": "sha512-9oRbuTC/VshB66Rgwi3pTq3sPxSTIb8k9L1vJjES+dDMKa29DAjPtWCXG/pyZ00ufpFZgkGEuAHH5uqUcr1JQg==", + "resolved": "https://registry.npmjs.org/@swc/core-darwin-arm64/-/core-darwin-arm64-1.3.62.tgz", + "integrity": "sha512-MmGilibITz68LEje6vJlKzc2gUUSgzvB3wGLSjEORikTNeM7P8jXVxE4A8fgZqDeudJUm9HVWrxCV+pHDSwXhA==", "cpu": [ - "x64" + "arm64" ], "dev": true, "optional": true, "os": [ - "linux" + "darwin" ], "engines": { "node": ">=10" @@ -4222,9 +4226,9 @@ "dev": true }, "node_modules/acorn": { - "version": "8.9.0", - "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.9.0.tgz", - "integrity": "sha512-jaVNAFBHNLXspO543WnNNPZFRtavh3skAkITqD0/2aeMkKZTN+254PyhwxFYrk3vQ1xfY+2wbesJMs/JC8/PwQ==", + "version": "8.10.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.10.0.tgz", + "integrity": "sha512-F0SAmZ8iUtS//m8DmCTA0jlh6TDKkHQyK6xc6V4KDTyZKA9dnvX9/3sRTVQrWm79glUAZbnmmNcdYwUIHWVybw==", "dev": true, "bin": { "acorn": "bin/acorn" @@ -4696,9 +4700,9 @@ } }, "node_modules/babel-plugin-polyfill-corejs2/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -5612,6 +5616,17 @@ "url": "https://github.com/sponsors/mesqueeb" } }, + "node_modules/core-js": { + "version": "3.31.1", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.31.1.tgz", + "integrity": "sha512-2sKLtfq1eFST7l7v62zaqXacPc7uG8ZAya8ogijLhTtaKNcpzpB4TMoTw2Si+8GYKRwFPMMtUT0263QFWFfqyQ==", + "dev": true, + "hasInstallScript": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, "node_modules/core-js-compat": { "version": "3.30.2", "resolved": "https://registry.npmjs.org/core-js-compat/-/core-js-compat-3.30.2.tgz", @@ -6771,6 +6786,89 @@ "bser": "2.1.1" } }, + "node_modules/fetch-mock": { + "version": "9.11.0", + "resolved": "https://registry.npmjs.org/fetch-mock/-/fetch-mock-9.11.0.tgz", + "integrity": "sha512-PG1XUv+x7iag5p/iNHD4/jdpxL9FtVSqRMUQhPab4hVDt80T1MH5ehzVrL2IdXO9Q2iBggArFvPqjUbHFuI58Q==", + "dev": true, + "dependencies": { + "@babel/core": "^7.0.0", + "@babel/runtime": "^7.0.0", + "core-js": "^3.0.0", + "debug": "^4.1.1", + "glob-to-regexp": "^0.4.0", + "is-subset": "^0.1.1", + "lodash.isequal": "^4.5.0", + "path-to-regexp": "^2.2.1", + "querystring": "^0.2.0", + "whatwg-url": "^6.5.0" + }, + "engines": { + "node": ">=4.0.0" + }, + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + } + }, + "node_modules/fetch-mock-jest": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/fetch-mock-jest/-/fetch-mock-jest-1.5.1.tgz", + "integrity": "sha512-+utwzP8C+Pax1GSka3nFXILWMY3Er2L+s090FOgqVNrNCPp0fDqgXnAHAJf12PLHi0z4PhcTaZNTz8e7K3fjqQ==", + "dev": true, + "dependencies": { + "fetch-mock": "^9.11.0" + }, + "engines": { + "node": ">=8.0.0" + }, + "funding": { + "type": "charity", + "url": "https://www.justgiving.com/refugee-support-europe" + }, + "peerDependencies": { + "node-fetch": "*" + }, + "peerDependenciesMeta": { + "node-fetch": { + "optional": true + } + } + }, + "node_modules/fetch-mock/node_modules/tr46": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-1.0.1.tgz", + "integrity": "sha512-dTpowEjclQ7Kgx5SdBkqRzVhERQXov8/l9Ft9dVM9fmg0W0KQSVaXX9T4i6twCPNtYiZM53lpSSUAwJbFPOHxA==", + "dev": true, + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/fetch-mock/node_modules/webidl-conversions": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-4.0.2.tgz", + "integrity": "sha512-YQ+BmxuTgd6UXZW3+ICGfyqRyHXVlD5GtQr5+qjiNW7bF0cqrzX500HVXPBOvgXb5YnzDd+h0zqyv61KUD7+Sg==", + "dev": true + }, + "node_modules/fetch-mock/node_modules/whatwg-url": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-6.5.0.tgz", + "integrity": "sha512-rhRZRqx/TLJQWUpQ6bmrt2UV4f0HCQ463yQuONJqC6fO2VoEb1pTYddbe59SkYq87aoM5A3bdhMZiUiVws+fzQ==", + "dev": true, + "dependencies": { + "lodash.sortby": "^4.7.0", + "tr46": "^1.0.1", + "webidl-conversions": "^4.0.2" + } + }, "node_modules/figures": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/figures/-/figures-3.2.0.tgz", @@ -7164,6 +7262,12 @@ "node": ">= 6" } }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/glob-to-regexp/-/glob-to-regexp-0.4.1.tgz", + "integrity": "sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==", + "dev": true + }, "node_modules/glob/node_modules/minimatch": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", @@ -7774,6 +7878,12 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-subset": { + "version": "0.1.1", + "resolved": "https://registry.npmjs.org/is-subset/-/is-subset-0.1.1.tgz", + "integrity": "sha512-6Ybun0IkarhmEqxXCNw/C0bna6Zb/TkfUX9UbwJtK6ObwAVCxmAP308WWTHviM/zAqXk05cdhYsUsZeGQh99iw==", + "dev": true + }, "node_modules/is-what": { "version": "4.1.15", "resolved": "https://registry.npmjs.org/is-what/-/is-what-4.1.15.tgz", @@ -7829,9 +7939,9 @@ } }, "node_modules/istanbul-lib-instrument/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -8512,6 +8622,76 @@ "node": "^14.15.0 || ^16.10.0 || >=18.0.0" } }, + "node_modules/jest-websocket-mock": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.4.0.tgz", + "integrity": "sha512-AOwyuRw6fgROXHxMOiTDl1/T4dh3fV4jDquha5N0csS/PNp742HeTZWPAuKppVRSQ8s3fUGgJHoyZT9JDO0hMA==", + "dev": true, + "dependencies": { + "jest-diff": "^28.0.2", + "mock-socket": "^9.1.0" + } + }, + "node_modules/jest-websocket-mock/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/jest-websocket-mock/node_modules/diff-sequences": { + "version": "28.1.1", + "resolved": "https://registry.npmjs.org/diff-sequences/-/diff-sequences-28.1.1.tgz", + "integrity": "sha512-FU0iFaH/E23a+a718l8Qa/19bF9p06kgE0KipMOMadwa3SjnaElKzPaUC0vnibs6/B/9ni97s61mcejk8W1fQw==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-websocket-mock/node_modules/jest-diff": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/jest-diff/-/jest-diff-28.1.3.tgz", + "integrity": "sha512-8RqP1B/OXzjjTWkqMX67iqgwBVJRgCyKD3L9nq+6ZqJMdvjE8RgHktqZ6jNrkdMT+dJuYNI3rhQpxaz7drJHfw==", + "dev": true, + "dependencies": { + "chalk": "^4.0.0", + "diff-sequences": "^28.1.1", + "jest-get-type": "^28.0.2", + "pretty-format": "^28.1.3" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-websocket-mock/node_modules/jest-get-type": { + "version": "28.0.2", + "resolved": "https://registry.npmjs.org/jest-get-type/-/jest-get-type-28.0.2.tgz", + "integrity": "sha512-ioj2w9/DxSYHfOm5lJKCdcAmPJzQXmbM/Url3rhlghrPvT3tt+7a/+oXc9azkKmLvoiXjtV83bEWqi+vs5nlPA==", + "dev": true, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, + "node_modules/jest-websocket-mock/node_modules/pretty-format": { + "version": "28.1.3", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-28.1.3.tgz", + "integrity": "sha512-8gFb/To0OmxHR9+ZTb14Df2vNxdGCX8g1xWGUTqUw5TiZvcQf5sHKObd5UcPyLLyowNwDAMTF3XWOG1B6mxl1Q==", + "dev": true, + "dependencies": { + "@jest/schemas": "^28.1.3", + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^18.0.0" + }, + "engines": { + "node": "^12.13.0 || ^14.15.0 || ^16.10.0 || >=17.0.0" + } + }, "node_modules/jest-worker": { "version": "29.5.0", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-29.5.0.tgz", @@ -8853,6 +9033,12 @@ "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", "dev": true }, + "node_modules/lodash.sortby": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz", + "integrity": "sha512-HDWXG8isMntAyRF5vZ7xKuEvOhT4AhlRt/3czTSjvGUxjYCBVRQY48ViDHyfYz9VIoBkW4TMGQNapx+l3RUwdA==", + "dev": true + }, "node_modules/lodash.uniq": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.uniq/-/lodash.uniq-4.5.0.tgz", @@ -8906,9 +9092,9 @@ } }, "node_modules/make-dir/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -9079,6 +9265,15 @@ "mkdirp": "bin/cmd.js" } }, + "node_modules/mock-socket": { + "version": "9.2.1", + "resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.2.1.tgz", + "integrity": "sha512-aw9F9T9G2zpGipLLhSNh6ZpgUyUl4frcVmRN08uE1NWPWg43Wx6+sGPDbQ7E5iFZZDJW5b5bypMeAEHqTbIFag==", + "dev": true, + "engines": { + "node": ">= 8" + } + }, "node_modules/ms": { "version": "2.1.2", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz", @@ -9164,9 +9359,9 @@ } }, "node_modules/node-gyp-build": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.6.0.tgz", - "integrity": "sha512-NTZVKn9IylLwUzaKjkas1e4u2DLNcV4rdYagA4PWdPwW87Bi7z+BznyKSRwS/761tV/lzCGXplWsiaMjLqP2zQ==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.5.0.tgz", + "integrity": "sha512-2iGbaQBV+ITgCz76ZEjmhUKAKVf7xfY1sRl4UiKQspfZMH2h06SyhNsnSVy50cwkFQDGLyif6m/6uFXHkOZ6rg==", "dev": true, "bin": { "node-gyp-build": "bin.js", @@ -9686,6 +9881,12 @@ "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", "dev": true }, + "node_modules/path-to-regexp": { + "version": "2.4.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-2.4.0.tgz", + "integrity": "sha512-G6zHoVqC6GGTQkZwF4lkuEyMbVOjoBKAEybQUypI1WTkqinCOrq2x6U2+phkJ1XsEMTy4LjtwPI7HW+NVrRR2w==", + "dev": true + }, "node_modules/path-type": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/path-type/-/path-type-4.0.0.tgz", @@ -10447,9 +10648,9 @@ } }, "node_modules/protobufjs": { - "version": "7.2.4", - "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.2.4.tgz", - "integrity": "sha512-AT+RJgD2sH8phPmCf7OUZR8xGdcJRga4+1cOaXJ64hvcSkVhNcRHOwIxUatPH15+nj59WAGTDv3LSGZPEQbJaQ==", + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.1.2.tgz", + "integrity": "sha512-4ZPTPkXCdel3+L81yw3dG6+Kq3umdWKh7Dc7GW/CpNk4SX3hK58iPCWeCyhVTDrbkNeKrYNZ7EojM5WDaEWTLQ==", "hasInstallScript": true, "dependencies": { "@protobufjs/aspromise": "^1.1.2", @@ -10541,6 +10742,16 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/querystring": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/querystring/-/querystring-0.2.1.tgz", + "integrity": "sha512-wkvS7mL/JMugcup3/rMitHmd9ecIGd2lhFhK9N3UUQ450h66d1r3Y9nvXzQAW1Lq+wyx61k/1pfKS5KuKiyEbg==", + "deprecated": "The querystring API is considered Legacy. new code should use the URLSearchParams API instead.", + "dev": true, + "engines": { + "node": ">=0.4.x" + } + }, "node_modules/querystringify": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/querystringify/-/querystringify-2.2.0.tgz", @@ -11100,9 +11311,9 @@ } }, "node_modules/semver-truncate/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz", + "integrity": "sha512-b39TBaTSfV6yBrapU89p5fKekE2m/NwnDocOVruQFS1/veMgdzuPcnOM34M6CwxW8jH/lxEa5rBoDeUwu5HHTw==", "dev": true, "bin": { "semver": "bin/semver.js" @@ -11627,9 +11838,9 @@ } }, "node_modules/tough-cookie": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.3.tgz", - "integrity": "sha512-aX/y5pVRkfRnfmuX+OdbSdXvPe6ieKX/G2s7e98f4poJHnqH3281gDPm/metm6E/WRamfx7WC4HUqkWHfQHprw==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-4.1.2.tgz", + "integrity": "sha512-G9fqXWoYFZgTc2z8Q5zaHy/vJMjm+WV0AkAeHxVCQiEB1b+dGvWzFW6QV07cY5jQ5gRkeid2qIkzkxUnmoQZUQ==", "dev": true, "dependencies": { "psl": "^1.1.33", @@ -11773,9 +11984,9 @@ } }, "node_modules/tsconfig-paths": { - "version": "4.2.0", - "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.2.0.tgz", - "integrity": "sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==", + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-4.1.2.tgz", + "integrity": "sha512-uhxiMgnXQp1IR622dUXI+9Ehnws7i/y6xvpZB9IbUVOPy0muvdvgXeZOn88UcGPiT98Vp3rJPTa8bFoalZ3Qhw==", "dev": true, "dependencies": { "json5": "^2.2.2", diff --git a/package.json b/package.json index 781ca75a3..1bcf512e4 100644 --- a/package.json +++ b/package.json @@ -57,9 +57,11 @@ "babel-preset-minify": "0.5.2", "eslint": "~8.46.0", "eslint-config-prettier": "8.9.0", + "fetch-mock-jest": "^1.5.1", "jest": "^29.4.1", "jest-environment-jsdom": "^29.4.1", "jest-fetch-mock": "^3.0.3", + "jest-websocket-mock": "^2.4.0", "jsonc-eslint-parser": "^2.1.0", "nx": "16.5.5", "nx-cloud": "16.1.0", diff --git a/release-please-config.json b/release-please-config.json index f8008e6f6..0eb345104 100644 --- a/release-please-config.json +++ b/release-please-config.json @@ -57,6 +57,13 @@ "bump-minor-pre-major": true, "bump-patch-for-minor-pre-major": true, "versioning": "default" + }, + "libs/providers/go-feature-flag-web": { + "release-type": "node", + "prerelease": true, + "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 d27f1b56c..5b72a1f70 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -35,6 +35,9 @@ "@openfeature/go-feature-flag-provider": [ "libs/providers/go-feature-flag/src/index.ts" ], + "@openfeature/go-feature-flag-web-provider": [ + "libs/providers/go-feature-flag-web/src/index.ts" + ], "@openfeature/hooks-open-telemetry": [ "libs/hooks/open-telemetry/src/index.ts" ],