diff --git a/api/react.api.md b/api/react.api.md
new file mode 100644
index 00000000..d9386d7e
--- /dev/null
+++ b/api/react.api.md
@@ -0,0 +1,95 @@
+## API Report File for "@spotify-confidence/react"
+
+> Do not edit this file. It is a report generated by [API Extractor](https://api-extractor.com/).
+
+```ts
+
+import { Closer } from '@spotify-confidence/sdk';
+import { Confidence } from '@spotify-confidence/sdk';
+import { Configuration } from '@spotify-confidence/sdk';
+import { Context } from '@spotify-confidence/sdk';
+import { EventSender } from '@spotify-confidence/sdk';
+import { FC } from 'react';
+import { FlagEvaluation } from '@spotify-confidence/sdk';
+import { FlagResolver } from '@spotify-confidence/sdk';
+import { PropsWithChildren } from 'react';
+import { State } from '@spotify-confidence/sdk';
+import { StateObserver } from '@spotify-confidence/sdk';
+import { Trackable } from '@spotify-confidence/sdk';
+import { Value } from '@spotify-confidence/sdk';
+
+// Warning: (ae-missing-release-tag) "ConfidenceProvider" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+// Warning: (ae-missing-release-tag) "ConfidenceProvider" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export type ConfidenceProvider = FC> & {
+ WithContext: FC>;
+};
+
+// @public (undocumented)
+export const ConfidenceProvider: ConfidenceProvider;
+
+// Warning: (ae-missing-release-tag) "ConfidenceReact" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export class ConfidenceReact implements EventSender, Trackable, FlagResolver {
+ constructor(delegate: Confidence);
+ // (undocumented)
+ clearContext(): void;
+ // (undocumented)
+ get config(): Configuration;
+ // @internal (undocumented)
+ readonly delegate: Confidence;
+ // (undocumented)
+ evaluateFlag(path: string, defaultValue: T): FlagEvaluation>;
+ // (undocumented)
+ getContext(): Context;
+ // (undocumented)
+ getFlag(path: string, defaultValue: T): Promise>;
+ // (undocumented)
+ setContext(context: Context): void;
+ // @internal (undocumented)
+ get state(): State;
+ // (undocumented)
+ subscribe(onStateChange?: StateObserver | undefined): () => void;
+ // (undocumented)
+ track(name: string, message?: Value.Struct): void;
+ // (undocumented)
+ track(manager: Trackable.Manager): Closer;
+ // (undocumented)
+ useEvaluateFlag(path: string, defaultValue: T): FlagEvaluation>;
+ // (undocumented)
+ useFlag(path: string, defaultValue: T): Value.Widen;
+ // (undocumented)
+ useWithContext(context: Context): ConfidenceReact;
+ // (undocumented)
+ withContext(context: Context): ConfidenceReact;
+}
+
+// Warning: (ae-missing-release-tag) "useConfidence" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export const useConfidence: () => ConfidenceReact;
+
+// Warning: (ae-missing-release-tag) "useEvaluateFlag" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export function useEvaluateFlag(path: string, defaultValue: T, confidence?: ConfidenceReact): FlagEvaluation>;
+
+// Warning: (ae-missing-release-tag) "useFlag" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export function useFlag(path: string, defaultValue: T, confidence?: ConfidenceReact): Value.Widen;
+
+// Warning: (ae-missing-release-tag) "useWithContext" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export function useWithContext(context: Context, confidence?: ConfidenceReact): ConfidenceReact;
+
+// (No @packageDocumentation comment for this package)
+
+```
diff --git a/api/sdk.api.md b/api/sdk.api.md
index ec87c087..55750f78 100644
--- a/api/sdk.api.md
+++ b/api/sdk.api.md
@@ -4,7 +4,18 @@
```ts
-// Warning: (ae-forgotten-export) The symbol "Trackable" needs to be exported by the entry point index.d.ts
+// Warning: (ae-missing-release-tag) "Closer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+// Warning: (ae-missing-release-tag) "Closer" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export namespace Closer {
+ // (undocumented)
+ export function combine(...closers: Closer[]): Closer;
+}
+
+// @public (undocumented)
+export type Closer = () => void;
+
// Warning: (ae-missing-release-tag) "Confidence" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -12,8 +23,6 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
constructor(config: Configuration, parent?: Confidence);
// (undocumented)
clearContext(): void;
- // Warning: (ae-forgotten-export) The symbol "Configuration" needs to be exported by the entry point index.d.ts
- //
// (undocumented)
readonly config: Configuration;
// Warning: (ae-forgotten-export) The symbol "Subscribe" needs to be exported by the entry point index.d.ts
@@ -27,17 +36,21 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
// (undocumented)
evaluateFlag(path: string, defaultValue: T): FlagEvaluation>;
// (undocumented)
+ get flagState(): State;
+ // (undocumented)
getContext(): Context;
// (undocumented)
getFlag(path: string, defaultValue: T): Promise>;
+ // Warning: (ae-forgotten-export) The symbol "AccessiblePromise" needs to be exported by the entry point index.d.ts
+ //
// (undocumented)
- setContext(context: Context): void;
+ protected resolveFlags(): AccessiblePromise;
+ // (undocumented)
+ setContext(context: Context): boolean;
// (undocumented)
subscribe(onStateChange?: StateObserver): () => void;
// (undocumented)
track(name: string, data?: EventData): void;
- // Warning: (ae-forgotten-export) The symbol "Closer" needs to be exported by the entry point index.d.ts
- //
// (undocumented)
track(manager: Trackable.Manager): Closer;
// (undocumented)
@@ -68,6 +81,26 @@ export interface ConfidenceOptions {
timeout: number;
}
+// Warning: (ae-missing-release-tag) "Configuration" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export interface Configuration {
+ // (undocumented)
+ readonly environment: 'client' | 'backend';
+ // Warning: (ae-forgotten-export) The symbol "EventSenderEngine" needs to be exported by the entry point index.d.ts
+ //
+ // @internal (undocumented)
+ readonly eventSenderEngine: EventSenderEngine;
+ // Warning: (ae-forgotten-export) The symbol "FlagResolverClient" needs to be exported by the entry point index.d.ts
+ //
+ // @internal (undocumented)
+ readonly flagResolverClient: FlagResolverClient;
+ // (undocumented)
+ readonly logger: Logger;
+ // (undocumented)
+ readonly timeout: number;
+}
+
// Warning: (ae-missing-release-tag) "Context" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
// @public (undocumented)
@@ -187,6 +220,27 @@ export type State = 'NOT_READY' | 'READY' | 'STALE' | 'ERROR';
// @public (undocumented)
export type StateObserver = (state: State) => void;
+// Warning: (ae-missing-release-tag) "Trackable" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+// Warning: (ae-missing-release-tag) "Trackable" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
+//
+// @public (undocumented)
+export namespace Trackable {
+ // (undocumented)
+ export type Cleanup = void | Closer;
+ // (undocumented)
+ export type Controller = Pick;
+ // (undocumented)
+ export type Manager = (controller: Controller) => Cleanup;
+ // (undocumented)
+ export function setup(controller: Controller, manager: Manager): Closer;
+}
+
+// @public (undocumented)
+export interface Trackable {
+ // (undocumented)
+ track(manager: Trackable.Manager): Closer;
+}
+
// Warning: (ae-missing-release-tag) "Value" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
// Warning: (ae-missing-release-tag) "Value" is part of the package's API, but it is missing a release tag (@alpha, @beta, @public, or @internal)
//
@@ -209,6 +263,8 @@ export namespace Value {
// (undocumented)
export function clone(value: T): T;
// (undocumented)
+ export function deserialize(data: string): Value;
+ // (undocumented)
export function equal(value1: Value, value2: Value): boolean;
// (undocumented)
export function get(struct: Struct | undefined, path: string): Value;
@@ -225,6 +281,8 @@ export namespace Value {
// (undocumented)
export type Primitive = number | string | boolean;
// (undocumented)
+ export function serialize(value: Value): string;
+ // (undocumented)
export type Struct = {
readonly [key: string]: Value;
};
diff --git a/examples/react18/package.json b/examples/react18/package.json
index 90ad173e..411dd744 100644
--- a/examples/react18/package.json
+++ b/examples/react18/package.json
@@ -6,10 +6,7 @@
"@babel/core": "^7.23.2",
"@babel/plugin-syntax-flow": "^7.22.5",
"@babel/plugin-transform-react-jsx": "^7.22.15",
- "@openfeature/core": "^1.1.0",
- "@openfeature/react-sdk": "^0.3.3",
- "@openfeature/web-sdk": "^1.0.3",
- "@spotify-confidence/openfeature-web-provider": "0.2.5",
+ "@spotify-confidence/react": "0.0.1",
"@spotify-confidence/sdk": "0.0.7",
"@testing-library/dom": "^7.29.6",
"@testing-library/jest-dom": "^5.14.1",
@@ -17,8 +14,6 @@
"@testing-library/user-event": "^13.2.1",
"@types/jest": "^27.0.1",
"@types/node": "^16.7.13",
- "@types/react": "^18.0.0",
- "@types/react-dom": "^18.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-scripts": "5.0.1",
diff --git a/examples/react18/src/App.tsx b/examples/react18/src/App.tsx
index 9a6725b9..9816eecb 100644
--- a/examples/react18/src/App.tsx
+++ b/examples/react18/src/App.tsx
@@ -1,33 +1,30 @@
import React from 'react';
-import { OpenFeature, OpenFeatureProvider } from '@openfeature/react-sdk';
import TestComponent from './TestComponent';
-import { createConfidenceWebProvider } from '@spotify-confidence/openfeature-web-provider';
import { Confidence, pageViews } from '@spotify-confidence/sdk';
-import { ConfidenceProvider } from './ConfidenceContext';
+import { ConfidenceProvider } from '@spotify-confidence/react';
const confidence = Confidence.create({
clientSecret: 'RxDVTrXvc6op1XxiQ4OaR31dKbJ39aYV',
region: 'eu',
environment: 'client',
timeout: 1000,
+ logger: console,
});
confidence.track(pageViews());
-const webProvider = createConfidenceWebProvider(confidence);
-OpenFeature.setProvider(webProvider);
function App() {
return (
React 18 Example
-
-
-
Loading... }>
+
+ Loading... }>
+
-
-
- bottom
-
+
+
+
+ bottom
);
}
diff --git a/examples/react18/src/ConfidenceContext.tsx b/examples/react18/src/ConfidenceContext.tsx
deleted file mode 100644
index 473b866c..00000000
--- a/examples/react18/src/ConfidenceContext.tsx
+++ /dev/null
@@ -1,22 +0,0 @@
-import { Confidence, Context } from '@spotify-confidence/sdk';
-import { createContext, FC, PropsWithChildren, useContext, useMemo } from 'react';
-
-const ConfidenceContext = createContext(null);
-
-export const ConfidenceProvider: FC> = ({ confidence, children }) => (
-
-);
-
-export const WithConfidenceContext: FC> = ({ context, children }) => {
- return {children} ;
-};
-
-export const useConfidence = (withContext?: Context): Confidence => {
- const parent = useContext(ConfidenceContext);
- if (!parent)
- throw new Error('No Confidence instance found, did you forget to wrap your component in ConfidenceProvider?');
-
- return useMemo(() => {
- return withContext ? parent.withContext(withContext) : parent;
- }, [parent, JSON.stringify(withContext)]);
-};
diff --git a/examples/react18/src/TestComponent.tsx b/examples/react18/src/TestComponent.tsx
index 950508d3..adc2af9e 100644
--- a/examples/react18/src/TestComponent.tsx
+++ b/examples/react18/src/TestComponent.tsx
@@ -1,27 +1,54 @@
-import { useState } from 'react';
-import { useStringFlagValue } from '@openfeature/react-sdk';
-import { useConfidence } from './ConfidenceContext';
-import { OpenFeature } from '@openfeature/web-sdk';
+import { useConfidence } from '@spotify-confidence/react';
+import { createContext, useContext, useState } from 'react';
+
+const fakeContext = createContext(undefined);
const TestComponent = () => {
- const str = useStringFlagValue('web-sdk-e2e-flag.str', 'default');
const [clickCount, setClickCount] = useState(0);
- const confidence = useConfidence({ component: 'Test' });
+ const confidence = useConfidence();
+ // const details = useFlagEvaluation('web-sdk-e2e-flag.str', 'default');
+ // const details = confidence.evaluateFlag('web-sdk-e2e-flag.str', 'default');
+ const details = confidence.useFlag('web-sdk-e2e-flag.str', 'default');
+ // const details = useFlagValue('web-sdk-e2e-flag.str', 'default');
return (
<>
- The string flag value is: {str}
+ The flag is:
+ {JSON.stringify(details, null, ' ')}
Click count is: {clickCount}
{
- OpenFeature.setContext({ targetingKey: `user-${Math.random()}` });
confidence.track('click');
setClickCount(value => value + 1);
}}
>
- Randomise OpenFeature Context
+ Click
+
+ {
+ let { targeting_key } = confidence.getContext();
+ console.log('got targeting key:', targeting_key);
+ if (targeting_key === 'user-a') {
+ targeting_key = 'user-b';
+ } else {
+ targeting_key = 'user-a';
+ }
+ confidence.setContext({ targeting_key });
+ }}
+ >
+ Randomise Context
>
);
};
export default TestComponent;
+
+function isRendering(): boolean {
+ try {
+ // eslint-disable-next-line
+ useContext(fakeContext);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
diff --git a/packages/react/CHANGELOG.md b/packages/react/CHANGELOG.md
new file mode 100644
index 00000000..e69de29b
diff --git a/packages/react/README.md b/packages/react/README.md
new file mode 100644
index 00000000..86041063
--- /dev/null
+++ b/packages/react/README.md
@@ -0,0 +1,70 @@
+# Confidence React SDK
+
+![](https://img.shields.io/badge/lifecycle-beta-a0c3d2.svg)
+
+This package contains helper functionality to make the Confidence SDK work well in a React environment.
+
+## Usage
+
+### Adding the dependencies
+
+To add the packages to your dependencies run:
+
+```sh
+yarn add @spotify-confidence/react
+```
+
+### Initializing the ConfidenceProvider
+
+The Confidence React integration has a Provider that needs to be initialized. It accepts a Confidence instance and should wrap your component tree.
+
+```ts
+import { Confidence } from '@spotify-confidence/sdk';
+
+const confidence = Confidence.create({
+ clientSecret: 'mysecret',
+ region: 'eu',
+ environment: 'client',
+ timeout: 1000,
+});
+
+function App() {
+ return (
+
+ Loading...
}>
+
+
+
+ );
+}
+```
+
+### Managing context
+
+The `useConfidence()` hook supports the [standard context API's](https://github.com/spotify/confidence-sdk-js/blob/main/packages/sdk/README.md#setting-the-context). Additionally, the following wrapper component can be used to wrap a sub tree with additional context data.
+
+```ts
+
+
+
+```
+
+### Accessing flags
+
+Flags are accessed with a set of hooks exported from `@spotify-confidence/react`
+
+- `useFlag(flagName, defaultValue)` will return the flag value or default.
+- `useEvaluateFlag(flagName, defaultValue)` will return more details about the flag evaluation, together with the value
+
+Both of the flag hooks integrate with the React Suspense API so that the suspense fallback will be visible until flag values are available. It is therefore important to wrap .
+
+Accessing flags will always attempt to provide a up to date value for the flag within the defined timeout, or else default values.
+
+### Tracking events
+
+The event tracking API is available on the Confidence instance as usual. See the [SDK Readme](https://github.com/spotify/confidence-sdk-js/blob/main/packages/sdk/README.md#event-tracking) for details.
+
+```ts
+const confidence = useConfidence();
+confidence.track('my-event-name', { my_data: 4 });
+```
diff --git a/packages/react/api-extractor.json b/packages/react/api-extractor.json
new file mode 100644
index 00000000..798d4c03
--- /dev/null
+++ b/packages/react/api-extractor.json
@@ -0,0 +1,3 @@
+{
+ "extends": "../../api-extractor-base.json"
+}
diff --git a/packages/react/package.json b/packages/react/package.json
new file mode 100644
index 00000000..5cfba62d
--- /dev/null
+++ b/packages/react/package.json
@@ -0,0 +1,35 @@
+{
+ "name": "@spotify-confidence/react",
+ "license": "Apache-2.0",
+ "version": "0.0.1",
+ "types": "build/types/index.d.ts",
+ "files": [
+ "dist/index.*"
+ ],
+ "scripts": {
+ "bundle": "rollup -c && api-extractor run",
+ "build": "tsc"
+ },
+ "engineStrict": true,
+ "engines": {
+ "node": ">=18.17.0"
+ },
+ "publishConfig": {
+ "registry": "https://registry.npmjs.org/",
+ "access": "public",
+ "types": "dist/index.d.ts",
+ "main": "dist/index.js"
+ },
+ "peerDependencies": {
+ "@spotify-confidence/sdk": "0.0.7",
+ "react": "^18.2.0"
+ },
+ "devDependencies": {
+ "@microsoft/api-extractor": "*",
+ "@spotify-confidence/sdk": "0.0.7",
+ "react": "^18.2.0",
+ "rollup": "*"
+ },
+ "type": "module",
+ "main": "dist/index.js"
+}
diff --git a/packages/react/rollup.config.mjs b/packages/react/rollup.config.mjs
new file mode 100644
index 00000000..61d795fe
--- /dev/null
+++ b/packages/react/rollup.config.mjs
@@ -0,0 +1 @@
+export { default } from '../../rollup.base.mjs';
diff --git a/packages/react/src/index.tsx b/packages/react/src/index.tsx
new file mode 100644
index 00000000..0e0bba23
--- /dev/null
+++ b/packages/react/src/index.tsx
@@ -0,0 +1,187 @@
+import {
+ Closer,
+ Confidence,
+ Value,
+ FlagEvaluation,
+ Context,
+ Configuration,
+ EventSender,
+ FlagResolver,
+ StateObserver,
+ Trackable,
+ State,
+} from '@spotify-confidence/sdk';
+
+import React, { createContext, FC, PropsWithChildren, useContext, useEffect, useMemo, useState } from 'react';
+
+const ConfidenceContext = createContext(null);
+
+function isRendering(): boolean {
+ try {
+ // eslint-disable-next-line
+ useContext(ConfidenceContext);
+ return true;
+ } catch (e) {
+ return false;
+ }
+}
+
+export class ConfidenceReact implements EventSender, Trackable, FlagResolver {
+ /** @internal */
+ readonly delegate: Confidence;
+
+ constructor(delegate: Confidence) {
+ this.delegate = delegate;
+ }
+
+ get config(): Configuration {
+ return this.delegate.config;
+ }
+
+ /** @internal */
+ get state(): State {
+ return this.delegate.flagState;
+ }
+
+ track(name: string, message?: Value.Struct): void;
+ track(manager: Trackable.Manager): Closer;
+ track(nameOrManager: string | Trackable.Manager, message?: Value.Struct): Closer | undefined {
+ if (typeof nameOrManager === 'function') {
+ return this.delegate.track(nameOrManager);
+ }
+ this.delegate.track(nameOrManager, message);
+ return undefined;
+ }
+ getContext(): Context {
+ return this.delegate.getContext();
+ }
+ setContext(context: Context): void {
+ this.delegate.setContext(context);
+ }
+ subscribe(onStateChange?: StateObserver | undefined): () => void {
+ return this.delegate.subscribe(onStateChange);
+ }
+ clearContext(): void {
+ this.delegate.clearContext();
+ }
+
+ withContext(context: Context): ConfidenceReact {
+ this.assertContext('withContext', 'useWithContext');
+ return new ConfidenceReact(this.delegate.withContext(context));
+ }
+ evaluateFlag(path: string, defaultValue: T): FlagEvaluation> {
+ this.assertContext('evaluateFlag', 'useEvaluateFlag');
+ return this.delegate.evaluateFlag(path, defaultValue);
+ }
+ getFlag(path: string, defaultValue: T): Promise> {
+ this.assertContext('getFlag', 'useFlag');
+ return this.delegate.getFlag(path, defaultValue);
+ }
+
+ /* eslint-disable react-hooks/rules-of-hooks */
+
+ useWithContext(context: Context): ConfidenceReact {
+ this.assertContext('useWithContext', 'withContext');
+ return useWithContext(context, this);
+ }
+ useEvaluateFlag(path: string, defaultValue: T): FlagEvaluation> {
+ this.assertContext('useEvaluateFlag', 'evaluateFlag');
+ return useEvaluateFlag(path, defaultValue, this);
+ }
+ useFlag(path: string, defaultValue: T): Value.Widen {
+ this.assertContext('useFlag', 'getFlag');
+ return useFlag(path, defaultValue, this);
+ }
+
+ /* eslint-enable react-hooks/rules-of-hooks */
+
+ private assertContext(fnName: string, altFnName: string) {
+ if (fnName.startsWith('use')) {
+ if (!isRendering())
+ throw new Error(
+ `${fnName} called outside the body of a function component. Did you mean to call ${altFnName}?`,
+ );
+ } else {
+ if (isRendering())
+ throw new Error(`${fnName} called inside the body of a function component. Did you mean to call ${altFnName}?`);
+ }
+ }
+}
+const _ConfidenceProvider: FC> = ({ confidence, children }) => {
+ const confidenceReact = useMemo(() => new ConfidenceReact(confidence), [confidence]);
+ return ;
+};
+
+const WithContext: FC> = ({ context, children }) => {
+ const child = useConfidence().useWithContext(context);
+ return {children} ;
+};
+
+export type ConfidenceProvider = FC> & {
+ WithContext: FC>;
+};
+// eslint-disable-next-line @typescript-eslint/no-redeclare
+export const ConfidenceProvider: ConfidenceProvider = Object.assign(_ConfidenceProvider, { WithContext });
+
+export const useConfidence = (): ConfidenceReact => {
+ const confidenceReact = useContext(ConfidenceContext);
+ if (!confidenceReact)
+ throw new Error('No Confidence instance found, did you forget to wrap your component in ConfidenceProvider?');
+ const [, setState] = useState(() =>
+ confidenceReact.state !== 'READY' ? '' : Value.serialize(confidenceReact.getContext()),
+ );
+
+ useEffect(
+ () =>
+ confidenceReact.subscribe(state => {
+ if (state === 'READY') setState(Value.serialize(confidenceReact.getContext()));
+ }),
+ [confidenceReact, setState],
+ );
+ return confidenceReact;
+};
+
+function useOptionalConfidence(confidence?: ConfidenceReact): ConfidenceReact {
+ try {
+ // to comply with hook-rules we always need to call useConfidence even if we don't need it.
+ const confidenceFromContext = useConfidence();
+ return confidence ?? confidenceFromContext;
+ } catch (e) {
+ if (!confidence) throw e;
+ return confidence;
+ }
+}
+
+export function useWithContext(context: Context, confidence?: ConfidenceReact): ConfidenceReact {
+ const parent = useOptionalConfidence(confidence);
+ const child = useMemo(
+ () => new ConfidenceReact(parent.delegate.withContext(context)),
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ [parent, Value.serialize(context)],
+ );
+
+ const [, setState] = useState(() => (child.state !== 'READY' ? '' : Value.serialize(child.getContext())));
+ useEffect(
+ () =>
+ child.subscribe(state => {
+ if (state === 'READY') setState(Value.serialize(child.getContext()));
+ }),
+ [child, setState],
+ );
+ return child;
+}
+
+export function useEvaluateFlag(
+ path: string,
+ defaultValue: T,
+ confidence?: ConfidenceReact,
+): FlagEvaluation> {
+ const evaluation = useOptionalConfidence(confidence).delegate.evaluateFlag(path, defaultValue);
+ // TODO make it a setting to _enable skip throwing_ on stale value.
+ if (evaluation.reason === 'ERROR' && 'then' in evaluation) throw evaluation;
+ return evaluation;
+}
+
+export function useFlag(path: string, defaultValue: T, confidence?: ConfidenceReact): Value.Widen {
+ return useEvaluateFlag(path, defaultValue, confidence).value;
+}
diff --git a/packages/react/tsconfig.json b/packages/react/tsconfig.json
new file mode 100644
index 00000000..e86c2dc8
--- /dev/null
+++ b/packages/react/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "extends": "../../tsconfig.common.json",
+ "compilerOptions": {
+ "rootDir": "src",
+ "outDir": "build/esm",
+ "declarationDir": "build/types",
+ "tsBuildInfoFile": "build/tsconfig.tsbuildinfo"
+ },
+ "references": [{ "path": "../sdk" }],
+ "include": ["src"]
+}
diff --git a/packages/sdk/src/AccessiblePromise.test.ts b/packages/sdk/src/AccessiblePromise.test.ts
new file mode 100644
index 00000000..7b9fd55b
--- /dev/null
+++ b/packages/sdk/src/AccessiblePromise.test.ts
@@ -0,0 +1,47 @@
+import { AccessiblePromise } from './AccessiblePromise';
+
+describe('AccessiblePromise', () => {
+ describe('resolved', () => {
+ const resolved = AccessiblePromise.resolve(237);
+
+ it('has state resolved', () => {
+ expect(resolved.state).toBe('RESOLVED');
+ });
+
+ it('can be awaited', async () => {
+ expect(await resolved).toBe(237);
+ });
+
+ it('synchronously returns a new AccessiblePromise on then', () => {
+ const other = resolved.then(value => value + 1);
+ expect(other.state).toBe('RESOLVED');
+ expect(other.or(0)).toBe(238);
+ });
+ });
+
+ describe('pending', () => {
+ let pending: AccessiblePromise;
+
+ beforeEach(() => {
+ pending = AccessiblePromise.resolve(tick().then(() => 237));
+ });
+ it('has state pending', () => {
+ expect(pending.state).toBe('PENDING');
+ });
+
+ it('can be awaited', async () => {
+ expect(await pending).toBe(237);
+ expect(pending.state).toBe('RESOLVED');
+ });
+
+ it('returns the alternative value', () => {
+ expect(pending.or(0)).toBe(0);
+ });
+ });
+});
+
+function tick(): Promise {
+ return new Promise(resolve => {
+ setTimeout(resolve, 0);
+ });
+}
diff --git a/packages/sdk/src/AccessiblePromise.ts b/packages/sdk/src/AccessiblePromise.ts
new file mode 100644
index 00000000..741c98a6
--- /dev/null
+++ b/packages/sdk/src/AccessiblePromise.ts
@@ -0,0 +1,117 @@
+function isPromiseLike(value: unknown): value is PromiseLike {
+ return typeof value === 'object' && value !== null && 'then' in value && typeof value.then === 'function';
+}
+
+export class AccessiblePromise {
+ #state: 'PENDING' | 'RESOLVED' | 'REJECTED';
+ #value: any;
+
+ protected constructor(value: any, rejected?: boolean) {
+ this.#value = value;
+ if (isPromiseLike(value)) {
+ // both value and reason can be promise like in which case we are still pending
+ this.#state = 'PENDING';
+ value.then(
+ v => {
+ this.#state = 'RESOLVED';
+ this.#value = v;
+ },
+ reason => {
+ this.#state = 'REJECTED';
+ this.#value = reason;
+ },
+ );
+ } else {
+ this.#state = rejected ? 'REJECTED' : 'RESOLVED';
+ }
+ }
+ get state(): 'PENDING' | 'RESOLVED' | 'REJECTED' {
+ return this.#state;
+ }
+ then(
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined,
+ onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined,
+ ): AccessiblePromise {
+ let value = this.#value;
+ let rejected = false;
+ // eslint-disable-next-line default-case
+ switch (this.#state) {
+ case 'PENDING':
+ value = value.then(onfulfilled, onrejected);
+ break;
+ case 'RESOLVED':
+ if (onfulfilled) {
+ try {
+ value = onfulfilled(value);
+ } catch (e) {
+ value = e;
+ rejected = true;
+ }
+ }
+ break;
+ case 'REJECTED':
+ if (onrejected) {
+ try {
+ value = onrejected(value);
+ } catch (e) {
+ value = e;
+ rejected = true;
+ }
+ }
+ break;
+ }
+ return new AccessiblePromise(value, rejected);
+ }
+
+ catch(
+ onrejected?: ((reason: any) => TResult | PromiseLike) | undefined | null,
+ ): AccessiblePromise {
+ return this.then(undefined, onrejected);
+ }
+
+ finally(onfinally?: (() => void) | undefined | null): AccessiblePromise {
+ return this.then(
+ value => {
+ onfinally?.();
+ return value;
+ },
+ reason => {
+ onfinally?.();
+ throw reason;
+ },
+ );
+ }
+
+ orSupply(supplier: () => T): T {
+ if (this.#state === 'RESOLVED') {
+ return this.#value as T;
+ }
+ if (this.#state === 'REJECTED') {
+ throw this.#value;
+ }
+ return supplier();
+ }
+ orSuspend(): T {
+ return this.orSupply(() => {
+ const error = new Error('Promise is not fulfilled.');
+ const then = this.#value.then.bind(this.#value);
+ throw Object.assign(error, { then });
+ });
+ }
+ orThrow(): T {
+ return this.orSupply(() => {
+ throw new Error('Promise is not fulfilled.');
+ });
+ }
+ or(value: T): T {
+ return this.orSupply(() => value);
+ }
+
+ static resolve(value?: T | PromiseLike): AccessiblePromise {
+ return new AccessiblePromise(value);
+ }
+
+ static reject(reason?: any): AccessiblePromise {
+ return new AccessiblePromise(reason, true);
+ }
+}
diff --git a/packages/sdk/src/Confidence.test.ts b/packages/sdk/src/Confidence.test.ts
index 85d7093e..429886ae 100644
--- a/packages/sdk/src/Confidence.test.ts
+++ b/packages/sdk/src/Confidence.test.ts
@@ -1,15 +1,16 @@
+import { AccessiblePromise } from './AccessiblePromise';
import { Closer } from './Closer';
import { Confidence } from './Confidence';
import { EventSenderEngine } from './EventSenderEngine';
-import { FlagResolution, FlagResolverClient } from './FlagResolverClient';
+import { FlagResolution } from './FlagResolution';
+import { FlagResolverClient, PendingResolution } from './FlagResolverClient';
import { FlagEvaluation, State, StateObserver } from './flags';
const flagResolverClientMock: jest.Mocked = {
resolve: jest.fn(),
-} as any; // TODO fix any by using an interface
+};
const eventSenderEngineMock: jest.Mocked = {} as any; // TODO fix any by using an interface
-const abortMock = jest.fn();
describe('Confidence', () => {
let confidence: Confidence;
@@ -29,7 +30,7 @@ describe('Confidence', () => {
flagResolverClient: flagResolverClientMock,
});
flagResolverClientMock.resolve.mockImplementation((context, _flags) => {
- const flagResolution = new Promise((resolve, _reject) => {
+ const flagResolution = new Promise(resolve => {
setTimeout(() => {
resolve({
context: context,
@@ -38,10 +39,7 @@ describe('Confidence', () => {
}, 0);
});
- return Object.assign(Promise.resolve(flagResolution), {
- context: {},
- abort: abortMock,
- });
+ return PendingResolution.create({}, () => flagResolution);
});
});
@@ -356,5 +354,22 @@ describe('Confidence', () => {
});
expect(flagResolverClientMock.resolve).toHaveBeenCalledTimes(1);
});
+
+ it('should handle a synchronously resolved promise', async () => {
+ const mockFlagResolution: FlagResolution = {
+ context: {},
+ evaluate: jest.fn().mockImplementation(() => matchedEvaluation),
+ };
+ const mockPendingResolution: PendingResolution = PendingResolution.create({}, () =>
+ AccessiblePromise.resolve(mockFlagResolution),
+ );
+ flagResolverClientMock.resolve.mockReturnValueOnce(mockPendingResolution);
+ const result = confidence.evaluateFlag('flag1', 'default');
+ expect(await result).toEqual({
+ reason: 'MATCH',
+ value: 'mockValue',
+ variant: 'mockVariant',
+ });
+ });
});
});
diff --git a/packages/sdk/src/Confidence.ts b/packages/sdk/src/Confidence.ts
index d40394af..a14b3c47 100644
--- a/packages/sdk/src/Confidence.ts
+++ b/packages/sdk/src/Confidence.ts
@@ -1,4 +1,9 @@
-import { FlagResolution, FlagResolverClient, PendingResolution } from './FlagResolverClient';
+import {
+ CachingFlagResolverClient,
+ FetchingFlagResolverClient,
+ FlagResolverClient,
+ PendingResolution,
+} from './FlagResolverClient';
import { EventSenderEngine } from './EventSenderEngine';
import { Value } from './Value';
import { EventData, EventSender } from './events';
@@ -11,8 +16,9 @@ import { Trackable } from './Trackable';
import { Closer } from './Closer';
import { Subscribe, Observer, subject, changeObserver } from './observing';
import { SimpleFetch } from './types';
+import { FlagResolution } from './FlagResolution';
+import { AccessiblePromise } from './AccessiblePromise';
-const NOOP = () => {};
export interface ConfidenceOptions {
clientSecret: string;
region?: 'eu' | 'us';
@@ -23,7 +29,7 @@ export interface ConfidenceOptions {
logger?: Logger;
}
-interface Configuration {
+export interface Configuration {
readonly environment: 'client' | 'backend';
readonly logger: Logger;
readonly timeout: number;
@@ -43,7 +49,7 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
readonly contextChanges: Subscribe;
private currentFlags?: FlagResolution;
- private pendingFlags?: PendingResolution;
+ private pendingFlags?: PendingResolution;
private readonly flagStateSubject: Subscribe;
@@ -71,8 +77,8 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
this.resolveFlags().then(reportState);
}
const close = this.contextChanges(() => {
+ if (this.flagState === 'READY') observer('STALE');
this.resolveFlags().then(reportState);
- reportState();
});
return () => {
@@ -116,7 +122,7 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
return Object.freeze(context);
}
- setContext(context: Context): void {
+ setContext(context: Context): boolean {
const current = this.getContext();
const changedKeys: string[] = [];
for (const key of Object.keys(context)) {
@@ -127,6 +133,7 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
if (this.contextChanged && changedKeys.length > 0) {
this.contextChanged(changedKeys);
}
+ return changedKeys.length > 0;
}
clearContext(): void {
@@ -143,6 +150,7 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
withContext(context: Context): Confidence {
const child = new Confidence(this.config, this);
child.setContext(context);
+ // child.resolveFlags();
return child;
}
@@ -156,30 +164,34 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
return undefined;
}
- private async resolveFlags(): Promise {
+ protected resolveFlags(): AccessiblePromise {
const context = this.getContext();
-
if (!this.pendingFlags || !Value.equal(this.pendingFlags.context, context)) {
- this.pendingFlags?.abort(new Error('Context changed'));
- this.pendingFlags = this.config.flagResolverClient.resolve(context, []);
- this.pendingFlags
+ this.pendingFlags?.abort();
+ this.pendingFlags = this.config.flagResolverClient
+ .resolve(context, [])
.then(resolution => {
this.currentFlags = resolution;
})
.catch(e => {
// TODO fix sloppy handling of error
- if (e.message !== 'Context changed') {
+ if (e.message !== 'Resolve aborted') {
this.config.logger.info?.('Resolve failed.', e);
}
})
.finally(() => {
+ // if this resolves synchronously, the assignment on 171 will actually happen after we clear it.
this.pendingFlags = undefined;
});
}
- return this.pendingFlags.then(NOOP, NOOP);
+ if (this.pendingFlags.state !== 'PENDING') {
+ this.pendingFlags = undefined;
+ return AccessiblePromise.resolve();
+ }
+ return this.pendingFlags;
}
- private get flagState(): State {
+ get flagState(): State {
if (this.currentFlags) {
if (this.pendingFlags) return 'STALE';
return 'READY';
@@ -212,6 +224,8 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
evaluateFlag(path: string, defaultValue: T): FlagEvaluation> {
let evaluation: FlagEvaluation;
+ // resolveFlags might update state synchronously
+ if (!this.currentFlags && !this.pendingFlags) this.resolveFlags();
if (!this.currentFlags) {
evaluation = {
reason: 'ERROR',
@@ -219,14 +233,17 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
errorMessage: 'Flags are not yet ready',
value: defaultValue,
};
- if (!this.pendingFlags) this.resolveFlags();
} else {
evaluation = this.currentFlags.evaluate(path, defaultValue);
}
if (!this.currentFlags || !Value.equal(this.currentFlags.context, this.getContext())) {
const then: PromiseLike>['then'] = (onfulfilled?, onrejected?) =>
this.evaluateFlagAsync(path, defaultValue).then(onfulfilled, onrejected);
- return Object.assign(evaluation, { then }) as FlagEvaluation>;
+ const staleEvaluation = {
+ ...evaluation,
+ then,
+ };
+ return staleEvaluation as FlagEvaluation>;
}
return evaluation as FlagEvaluation>;
}
@@ -247,13 +264,16 @@ export class Confidence implements EventSender, Trackable, FlagResolver {
id: SdkId.SDK_ID_JS_CONFIDENCE,
version: '0.0.7', // x-release-please-version
} as const;
- const flagResolverClient = new FlagResolverClient({
+ let flagResolverClient: FlagResolverClient = new FetchingFlagResolverClient({
clientSecret,
fetchImplementation,
sdk,
environment,
region,
});
+ if (environment === 'client') {
+ flagResolverClient = new CachingFlagResolverClient(flagResolverClient, 30_000);
+ }
const estEventSizeKb = 1;
const flushTimeoutMilliseconds = 500;
// default grpc payload limit is 4MB, so we aim for a 1MB batch-size
diff --git a/packages/sdk/src/FlagResolution.ts b/packages/sdk/src/FlagResolution.ts
new file mode 100644
index 00000000..55d71fc7
--- /dev/null
+++ b/packages/sdk/src/FlagResolution.ts
@@ -0,0 +1,155 @@
+import { Schema } from './Schema';
+import { Value } from './Value';
+import { TypeMismatchError } from './error';
+import { FlagEvaluation } from './flags';
+import { ResolveFlagsResponse } from './generated/confidence/flags/resolver/v1/api';
+import { ResolveReason } from './generated/confidence/flags/resolver/v1/types';
+
+const FLAG_PREFIX = 'flags/';
+
+export interface FlagResolution {
+ readonly context: Value.Struct;
+ // readonly flagNames:string[]
+ evaluate(path: string, defaultValue: T): FlagEvaluation.Resolved;
+}
+
+export namespace FlagResolution {
+ export function create(context: Value.Struct, response: ResolveFlagsResponse, applier?: Applier): FlagResolution {
+ return new FlagResolutionImpl(context, response, applier);
+ }
+}
+
+type ResolvedFlag = {
+ schema: Schema;
+ value: Value.Struct;
+ variant: string;
+ reason:
+ | 'UNSPECIFIED'
+ | 'MATCH'
+ | 'NO_SEGMENT_MATCH'
+ | 'NO_TREATMENT_MATCH'
+ | 'FLAG_ARCHIVED'
+ | 'TARGETING_KEY_ERROR'
+ | 'ERROR';
+};
+
+export type Applier = (flagName: string) => void;
+
+export class FlagResolutionImpl implements FlagResolution {
+ private readonly flags: Map = new Map();
+ // private readonly cachedEvaluations: Map]> =
+ // new Map();
+ readonly resolveToken: string;
+
+ constructor(
+ readonly context: Value.Struct,
+ resolveResponse: ResolveFlagsResponse,
+ private readonly applier?: Applier,
+ ) {
+ for (const { flag, variant, value, reason, flagSchema } of resolveResponse.resolvedFlags) {
+ const name = flag.slice(FLAG_PREFIX.length);
+
+ const schema = flagSchema ? Schema.parse({ structSchema: flagSchema }) : Schema.ANY;
+ this.flags.set(name, {
+ schema,
+ value: value! as Value.Struct,
+ variant,
+ reason: toEvaluationReason(reason),
+ });
+ }
+ this.resolveToken = base64FromBytes(resolveResponse.resolveToken);
+ }
+
+ doEvaluate(path: string, defaultValue: T): FlagEvaluation.Resolved {
+ try {
+ const [name, ...steps] = path.split('.');
+ const flag = this.flags.get(name);
+ if (!flag) {
+ return {
+ reason: 'ERROR',
+ value: defaultValue,
+ errorCode: 'FLAG_NOT_FOUND',
+ errorMessage: `Flag "${name}" not found`,
+ };
+ }
+ const reason = flag.reason;
+ if (reason === 'ERROR') throw new Error('Unknown resolve error');
+ if (reason !== 'MATCH') {
+ if (reason === 'NO_SEGMENT_MATCH' && this.applier) {
+ this.applier?.(name);
+ }
+ return {
+ reason,
+ value: defaultValue,
+ };
+ }
+
+ const value = TypeMismatchError.hoist(name, () => Value.get(flag.value, ...steps) as T);
+
+ const schema = flag.schema.get(...steps);
+ TypeMismatchError.hoist(['defaultValue', ...steps], () => {
+ schema.assertAssignsTo(defaultValue);
+ });
+
+ this.applier?.(name);
+ return {
+ reason,
+ value,
+ variant: flag.variant,
+ };
+ } catch (e: any) {
+ return {
+ reason: 'ERROR',
+ value: defaultValue,
+ errorCode: e instanceof TypeMismatchError ? 'TYPE_MISMATCH' : 'GENERAL',
+ errorMessage: e.message ?? 'Unknown error',
+ };
+ }
+ }
+ evaluate(path: string, defaultValue: T): FlagEvaluation.Resolved {
+ return this.doEvaluate(path, defaultValue);
+ // let entry = this.cachedEvaluations.get(path);
+ // if (!entry || !Value.equal(entry[0], defaultValue)) {
+ // entry = [defaultValue, this.doEvaluate(path, defaultValue)];
+ // // entry[1].id = Date.now();
+ // this.cachedEvaluations.set(path, entry);
+ // }
+ // return entry[1];
+ }
+
+ getValue(path: string, defaultValue: T): T {
+ return this.evaluate(path, defaultValue).value;
+ }
+}
+
+function toEvaluationReason(reason: ResolveReason): Exclude['reason'], 'PENDING'> {
+ switch (reason) {
+ case ResolveReason.RESOLVE_REASON_UNSPECIFIED:
+ return 'UNSPECIFIED';
+ case ResolveReason.RESOLVE_REASON_MATCH:
+ return 'MATCH';
+ case ResolveReason.RESOLVE_REASON_NO_SEGMENT_MATCH:
+ return 'NO_SEGMENT_MATCH';
+ case ResolveReason.RESOLVE_REASON_NO_TREATMENT_MATCH:
+ return 'NO_TREATMENT_MATCH';
+ case ResolveReason.RESOLVE_REASON_FLAG_ARCHIVED:
+ return 'FLAG_ARCHIVED';
+ case ResolveReason.RESOLVE_REASON_TARGETING_KEY_ERROR:
+ return 'TARGETING_KEY_ERROR';
+ case ResolveReason.RESOLVE_REASON_ERROR:
+ return 'ERROR';
+ default:
+ return 'UNSPECIFIED';
+ }
+}
+
+function base64FromBytes(arr: Uint8Array): string {
+ if ((globalThis as any).Buffer) {
+ return globalThis.Buffer.from(arr).toString('base64');
+ }
+ const bin: string[] = [];
+ arr.forEach(byte => {
+ bin.push(globalThis.String.fromCharCode(byte));
+ });
+ return globalThis.btoa(bin.join(''));
+}
diff --git a/packages/sdk/src/FlagResolverClient.test.ts b/packages/sdk/src/FlagResolverClient.test.ts
index 64ed18ec..61014c7a 100644
--- a/packages/sdk/src/FlagResolverClient.test.ts
+++ b/packages/sdk/src/FlagResolverClient.test.ts
@@ -1,18 +1,46 @@
-import { FlagResolverClient, withRequestLogic } from './FlagResolverClient';
+import { FetchingFlagResolverClient, withRequestLogic } from './FlagResolverClient';
import { setMaxListeners } from 'node:events';
import { SdkId } from './generated/confidence/flags/resolver/v1/types';
+import { abortableSleep } from './fetch-util';
+import { ApplyFlagsRequest, ResolveFlagsRequest } from './generated/confidence/flags/resolver/v1/api';
const RESOLVE_ENDPOINT = 'https://resolver.confidence.dev/v1/flags:resolve';
const APPLY_ENDPOINT = 'https://resolver.confidence.dev/v1/flags:apply';
setMaxListeners(50);
const dummyResolveToken = Uint8Array.from(atob('SGVsbG9Xb3JsZA=='), c => c.charCodeAt(0));
+
+const resolveHandlerMock = jest.fn();
+const applyHandlerMock = jest.fn();
+
+const fetchImplementation = async (request: Request): Promise => {
+ await abortableSleep(0, request.signal);
+
+ let handler: (reqBody: any) => any;
+ switch (request.url) {
+ case 'https://resolver.confidence.dev/v1/flags:resolve':
+ handler = data => resolveHandlerMock(ResolveFlagsRequest.fromJSON(data));
+ break;
+ case 'https://resolver.confidence.dev/v1/flags:apply':
+ handler = data => applyHandlerMock(ApplyFlagsRequest.fromJSON(data));
+ break;
+ default:
+ throw new Error(`Unknown url: ${request.url}`);
+ }
+ try {
+ const result = await handler(await request.json());
+ return new Response(JSON.stringify(result));
+ } catch (e: any) {
+ return new Response(null, { status: 500, statusText: e.message });
+ }
+};
+
+resolveHandlerMock.mockImplementation(createFlagResolutionResponse);
+
describe('Client environment Evaluation', () => {
- const mockFetch = jest.fn, [Request]>();
- const flagResolutionResponseJson = JSON.stringify(createFlagResolutionResponse());
- mockFetch.mockImplementation(async _ => new Response(flagResolutionResponseJson, { status: 200 }));
- const instanceUnderTest = new FlagResolverClient({
- fetchImplementation: mockFetch,
+ // const flagResolutionResponseJson = JSON.stringify(createFlagResolutionResponse());
+ const instanceUnderTest = new FetchingFlagResolverClient({
+ fetchImplementation,
clientSecret: 'secret',
applyTimeout: 10,
sdk: {
@@ -21,40 +49,14 @@ describe('Client environment Evaluation', () => {
},
environment: 'client',
});
- const applyMock = jest.fn();
- instanceUnderTest.apply = applyMock;
describe('apply', () => {
it('should send an apply event', async () => {
- jest.useFakeTimers();
const flagResolution = await instanceUnderTest.resolve({}, []);
flagResolution.evaluate('testflag.bool', false);
- await jest.runAllTimersAsync();
-
- expect(applyMock).toHaveBeenCalledWith(
- expect.objectContaining({
- clientSecret: 'secret',
- resolveToken: dummyResolveToken,
- sendTime: expect.any(Date),
- sdk: { id: 13, version: 'test' },
- flags: [
- {
- applyTime: expect.any(Date),
- flag: 'flags/testflag',
- },
- ],
- }),
- );
- });
- });
- it('should apply when a flag has no segment match', async () => {
- jest.useFakeTimers();
- const flagResolution = await instanceUnderTest.resolve({}, []);
- flagResolution.evaluate('no-seg-flag.enabled', false);
- await jest.runAllTimersAsync();
- expect(applyMock).toHaveBeenCalledWith(
- expect.objectContaining({
+ const [applyRequest] = await nextMockArgs(applyHandlerMock);
+ expect(applyRequest).toMatchObject({
clientSecret: 'secret',
resolveToken: dummyResolveToken,
sendTime: expect.any(Date),
@@ -62,21 +64,35 @@ describe('Client environment Evaluation', () => {
flags: [
{
applyTime: expect.any(Date),
- flag: 'flags/no-seg-flag',
+ flag: 'flags/testflag',
},
],
- }),
- );
+ });
+ });
+ });
+
+ it('should apply when a flag has no segment match', async () => {
+ const flagResolution = await instanceUnderTest.resolve({}, []);
+ flagResolution.evaluate('no-seg-flag.enabled', false);
+ const [applyRequest] = await nextMockArgs(applyHandlerMock);
+ expect(applyRequest).toMatchObject({
+ clientSecret: 'secret',
+ resolveToken: dummyResolveToken,
+ sendTime: expect.any(Date),
+ sdk: { id: 13, version: 'test' },
+ flags: [
+ {
+ applyTime: expect.any(Date),
+ flag: 'flags/no-seg-flag',
+ },
+ ],
+ });
});
});
describe('Backend environment Evaluation', () => {
- const mockFetch = jest.fn, [Request]>();
- mockFetch.mockImplementation(
- async _ => new Response(JSON.stringify(createFlagResolutionResponse()), { status: 200 }),
- );
- const instanceUnderTest = new FlagResolverClient({
- fetchImplementation: mockFetch,
+ const instanceUnderTest = new FetchingFlagResolverClient({
+ fetchImplementation,
clientSecret: 'secret',
sdk: {
id: SdkId.SDK_ID_JS_CONFIDENCE,
@@ -443,3 +459,16 @@ function createFlagResolutionResponse(): unknown {
resolveId: 'resolve-id',
};
}
+
+function nextMockArgs(mock: jest.Mock): Promise {
+ return new Promise(resolve => {
+ const realImpl = mock.getMockImplementation();
+ mock.mockImplementationOnce((...args: A) => {
+ try {
+ return realImpl?.(...args);
+ } finally {
+ resolve(args);
+ }
+ });
+ });
+}
diff --git a/packages/sdk/src/FlagResolverClient.ts b/packages/sdk/src/FlagResolverClient.ts
index 8b7eaaff..95e0a6a9 100644
--- a/packages/sdk/src/FlagResolverClient.ts
+++ b/packages/sdk/src/FlagResolverClient.ts
@@ -1,119 +1,65 @@
-import { Schema } from './Schema';
+import { AccessiblePromise } from './AccessiblePromise';
+import { Applier, FlagResolution } from './FlagResolution';
import { Value } from './Value';
import { Context } from './context';
-import { TypeMismatchError } from './error';
import { FetchBuilder, TimeUnit } from './fetch-util';
-import { FlagEvaluation } from './flags';
import {
ResolveFlagsRequest,
ResolveFlagsResponse,
ApplyFlagsRequest,
AppliedFlag,
} from './generated/confidence/flags/resolver/v1/api';
-import { ResolveReason, Sdk } from './generated/confidence/flags/resolver/v1/types';
+import { Sdk } from './generated/confidence/flags/resolver/v1/types';
import { SimpleFetch } from './types';
const FLAG_PREFIX = 'flags/';
-type ResolvedFlag = {
- schema: Schema;
- value: Value.Struct;
- variant: string;
- reason:
- | 'UNSPECIFIED'
- | 'MATCH'
- | 'NO_SEGMENT_MATCH'
- | 'NO_TREATMENT_MATCH'
- | 'FLAG_ARCHIVED'
- | 'TARGETING_KEY_ERROR'
- | 'ERROR';
-};
-
-type Applier = (flagName: string) => void;
-
-export interface FlagResolution {
- readonly context: Value.Struct;
- // readonly flagNames:string[]
- evaluate(path: string, defaultValue: T): FlagEvaluation.Resolved;
-}
+export class PendingResolution extends AccessiblePromise {
+ #context: Context;
+ #controller: AbortController;
-class FlagResolutionImpl implements FlagResolution {
- private readonly flags: Map = new Map();
- readonly resolveToken: string;
-
- constructor(
- readonly context: Value.Struct,
- resolveResponse: ResolveFlagsResponse,
- private readonly applier?: Applier,
- ) {
- for (const { flag, variant, value, reason, flagSchema } of resolveResponse.resolvedFlags) {
- const name = flag.slice(FLAG_PREFIX.length);
+ protected constructor(promise: PromiseLike, context: Context, controller: AbortController) {
+ super(promise);
+ this.#context = context;
+ this.#controller = controller;
+ }
- const schema = flagSchema ? Schema.parse({ structSchema: flagSchema }) : Schema.ANY;
- this.flags.set(name, {
- schema,
- value: value! as Value.Struct,
- variant,
- reason: toEvaluationReason(reason),
- });
- }
- this.resolveToken = base64FromBytes(resolveResponse.resolveToken);
+ get context(): Context {
+ return this.#context;
}
- evaluate(path: string, defaultValue: T): FlagEvaluation.Resolved {
- try {
- const [name, ...steps] = path.split('.');
- const flag = this.flags.get(name);
- if (!flag) {
- return {
- reason: 'ERROR',
- value: defaultValue,
- errorCode: 'FLAG_NOT_FOUND',
- errorMessage: `Flag "${name}" not found`,
- };
- }
- const reason = flag.reason;
- if (reason === 'ERROR') throw new Error('Unknown resolve error');
- if (reason !== 'MATCH') {
- if (reason === 'NO_SEGMENT_MATCH' && this.applier) {
- this.applier?.(name);
- }
- return {
- reason,
- value: defaultValue,
- };
- }
+ abort(): void {
+ this.#controller.abort(new Error('Resolve aborted'));
+ }
- const value = TypeMismatchError.hoist(name, () => Value.get(flag.value, ...steps) as T);
+ then(
+ onfulfilled?: ((value: T) => TResult1 | PromiseLike) | null | undefined,
+ onrejected?: ((reason: any) => TResult2 | PromiseLike) | null | undefined,
+ ): PendingResolution {
+ return new PendingResolution(super.then(onfulfilled, onrejected), this.#context, this.#controller);
+ }
- const schema = flag.schema.get(...steps);
- TypeMismatchError.hoist(['defaultValue', ...steps], () => {
- schema.assertAssignsTo(defaultValue);
- });
+ catch(
+ onrejected?: ((reason: any) => TResult | PromiseLike) | null | undefined,
+ ): PendingResolution {
+ return new PendingResolution(super.catch(onrejected), this.#context, this.#controller);
+ }
- this.applier?.(name);
- return {
- reason,
- value,
- variant: flag.variant,
- };
- } catch (e: any) {
- return {
- reason: 'ERROR',
- value: defaultValue,
- errorCode: e instanceof TypeMismatchError ? 'TYPE_MISMATCH' : 'GENERAL',
- errorMessage: e.message ?? 'Unknown error',
- };
- }
+ finally(onfinally?: (() => void) | null | undefined): PendingResolution {
+ return new PendingResolution(super.finally(onfinally), this.#context, this.#controller);
}
- getValue(path: string, defaultValue: T): T {
- return this.evaluate(path, defaultValue).value;
+
+ static create(
+ context: Context,
+ executor: (signal: AbortSignal) => PromiseLike,
+ ): PendingResolution {
+ const controller = new AbortController();
+ return new PendingResolution(executor(controller.signal), context, controller);
}
}
-export interface PendingResolution extends Promise {
- readonly context: Value.Struct;
- abort(reason?: any): void;
+export interface FlagResolverClient {
+ resolve(context: Context, flags: string[]): PendingResolution;
}
export type FlagResolverClientOptions = {
@@ -124,7 +70,8 @@ export type FlagResolverClientOptions = {
environment: 'client' | 'backend';
region?: 'eu' | 'us';
};
-export class FlagResolverClient {
+
+export class FetchingFlagResolverClient implements FlagResolverClient {
private readonly fetchImplementation: SimpleFetch;
private readonly clientSecret: string;
private readonly sdk: Sdk;
@@ -141,7 +88,7 @@ export class FlagResolverClient {
region,
}: FlagResolverClientOptions) {
// TODO think about both resolve and apply request logic for backends
- this.fetchImplementation = environment === 'client' ? withRequestLogic(fetchImplementation) : fetchImplementation;
+ this.fetchImplementation = environment === 'backend' ? fetchImplementation : withRequestLogic(fetchImplementation);
this.clientSecret = clientSecret;
this.sdk = sdk;
this.applyTimeout = applyTimeout;
@@ -156,12 +103,12 @@ export class FlagResolverClient {
sdk: this.sdk,
flags: flags.map(name => FLAG_PREFIX + name),
};
- const abortController = new AbortController();
- const resolution = this.resolveFlagsJson(request, abortController.signal).then(
- response => new FlagResolutionImpl(context, response, this.createApplier(response.resolveToken)),
- );
- return Object.assign(resolution, { context, abort: (reason?: any) => abortController.abort(reason) });
+ return PendingResolution.create(context, signal =>
+ this.resolveFlagsJson(request, signal).then(response =>
+ FlagResolution.create(context, response, this.createApplier(response.resolveToken)),
+ ),
+ );
}
createApplier(resolveToken: Uint8Array): Applier {
@@ -240,36 +187,46 @@ export class FlagResolverClient {
// }
}
-function toEvaluationReason(reason: ResolveReason): Exclude['reason'], 'PENDING'> {
- switch (reason) {
- case ResolveReason.RESOLVE_REASON_UNSPECIFIED:
- return 'UNSPECIFIED';
- case ResolveReason.RESOLVE_REASON_MATCH:
- return 'MATCH';
- case ResolveReason.RESOLVE_REASON_NO_SEGMENT_MATCH:
- return 'NO_SEGMENT_MATCH';
- case ResolveReason.RESOLVE_REASON_NO_TREATMENT_MATCH:
- return 'NO_TREATMENT_MATCH';
- case ResolveReason.RESOLVE_REASON_FLAG_ARCHIVED:
- return 'FLAG_ARCHIVED';
- case ResolveReason.RESOLVE_REASON_TARGETING_KEY_ERROR:
- return 'TARGETING_KEY_ERROR';
- case ResolveReason.RESOLVE_REASON_ERROR:
- return 'ERROR';
- default:
- return 'UNSPECIFIED';
+export class CachingFlagResolverClient implements FlagResolverClient {
+ readonly #cache: Map = new Map();
+ readonly #source: FlagResolverClient;
+ readonly #ttl: number;
+
+ constructor(source: FlagResolverClient, ttlMs: number) {
+ this.#source = source;
+ this.#ttl = ttlMs;
}
-}
-function base64FromBytes(arr: Uint8Array): string {
- if ((globalThis as any).Buffer) {
- return globalThis.Buffer.from(arr).toString('base64');
+ resolve(context: Context, flags: string[]): PendingResolution {
+ this.evict();
+ const key = Value.serialize(context);
+ let entry = this.#cache.get(key);
+ if (!entry) {
+ const value = this.#source.resolve(context, flags);
+ entry = { refCount: 1, timestamp: Date.now(), value };
+ // TODO This patching is too hacky
+ patch(value, 'abort', abort => () => {
+ entry!.refCount--;
+ if (entry!.refCount === 0) {
+ abort();
+ this.#cache.delete(key);
+ }
+ });
+ this.#cache.set(key, entry);
+ } else {
+ entry.refCount++;
+ }
+ return entry.value;
+ }
+
+ evict() {
+ const now = Date.now();
+ for (const [key, { timestamp }] of this.#cache) {
+ const age = now - timestamp;
+ if (age < this.#ttl) return;
+ this.#cache.delete(key);
+ }
}
- const bin: string[] = [];
- arr.forEach(byte => {
- bin.push(globalThis.String.fromCharCode(byte));
- });
- return globalThis.btoa(bin.join(''));
}
export function withRequestLogic(fetchImplementation: (request: Request) => Promise): typeof fetch {
@@ -304,3 +261,10 @@ export function withRequestLogic(fetchImplementation: (request: Request) => Prom
.build(request => Promise.reject(new Error(`Unexpected url: ${request.url}`)))
);
}
+
+function patch(obj: T, key: K, fn: (original: T[K]) => T[K]): T {
+ // @ts-ignore
+ const original = obj[key].bind(obj);
+ obj[key] = fn(original);
+ return obj;
+}
diff --git a/packages/sdk/src/Trackable.ts b/packages/sdk/src/Trackable.ts
index fee21cd4..a770084a 100644
--- a/packages/sdk/src/Trackable.ts
+++ b/packages/sdk/src/Trackable.ts
@@ -16,7 +16,7 @@ export namespace Trackable {
constructor(delegate: Controller) {
this.#delegate = delegate;
}
- setContext(context: Context): void {
+ setContext(context: Context): boolean {
this.assertNonRevoked();
return this.#delegate.setContext(context);
}
diff --git a/packages/sdk/src/Value.test.ts b/packages/sdk/src/Value.test.ts
index 0356e7e2..abf2099a 100644
--- a/packages/sdk/src/Value.test.ts
+++ b/packages/sdk/src/Value.test.ts
@@ -14,6 +14,32 @@ describe('Value', () => {
Value.get(value, 'a.a.b');
}).toThrow(`Expected Struct, but found string, at path 'a.a'`);
});
+
+ it('can handle undefined', () => {
+ expect(Value.serialize(undefined)).toBe('');
+ expect(Value.deserialize('')).toBeUndefined();
+ });
+ });
+
+ describe('serialization', () => {
+ it('it produces a canonical string', () => {
+ const s0 = Value.serialize({ a: 1, b: 2 });
+ const s1 = Value.serialize({ b: 2, a: 1 });
+ expect(s0).toBe(s1);
+ });
+
+ it('can read back an equal copy', () => {
+ const value: Value = {
+ a: 'hello',
+ b: 1234.56789,
+ c: true,
+ d: false,
+ e: [0.1, 0.2, 0.345345],
+ };
+ const data = Value.serialize(value);
+ const copy = Value.deserialize(data);
+ expect(copy).toEqual(value);
+ });
});
});
diff --git a/packages/sdk/src/Value.ts b/packages/sdk/src/Value.ts
index 2ab8b8c5..80328e49 100644
--- a/packages/sdk/src/Value.ts
+++ b/packages/sdk/src/Value.ts
@@ -149,6 +149,177 @@ export namespace Value {
/* eslint-enable no-loop-func */
return value;
}
+
+ export function serialize(value: Value): string {
+ const writer = new Writer();
+ writer.writeValue(value);
+ return writer.data;
+ }
+
+ export function deserialize(data: string): Value {
+ const reader = new Reader(data);
+ return reader.readValue();
+ }
+
+ const enum BinaryType {
+ STRING = 0,
+ NUMBER,
+ BOOLEAN_TRUE,
+ BOOLEAN_FALSE,
+ LIST,
+ STRUCT,
+ }
+ // 8 byte buffer for encoding one float64
+ const numberBuffer = new Uint16Array(4);
+ class Writer {
+ private readonly buffer: string[] = [];
+
+ get data(): string {
+ return this.buffer.join('');
+ }
+
+ // eslint-disable-next-line consistent-return
+ writeValue(value: Value) {
+ // eslint-disable-next-line default-case
+ switch (getType(value)) {
+ case 'string':
+ return this.writeString(value as string);
+ case 'number':
+ return this.writeNumber(value as number);
+ case 'boolean':
+ return this.writeBoolean(value as boolean);
+ case 'Struct':
+ return this.writeStruct(value as Struct);
+ case 'List':
+ return this.writeList(value as List);
+ case 'undefined':
+ // ignore
+ break;
+ }
+ }
+
+ writeString(value: string) {
+ this.buffer.push(String.fromCharCode(BinaryType.STRING, value.length), value);
+ }
+
+ writeNumber(value: number) {
+ new DataView(numberBuffer.buffer).setFloat64(0, value);
+ this.buffer.push(String.fromCharCode(BinaryType.NUMBER, ...numberBuffer));
+ }
+
+ writeBoolean(value: boolean) {
+ this.buffer.push(String.fromCharCode(value ? BinaryType.BOOLEAN_TRUE : BinaryType.BOOLEAN_FALSE));
+ }
+
+ writeList(list: List) {
+ this.buffer.push(String.fromCharCode(BinaryType.LIST, list.length));
+ for (const value of list) {
+ this.writeValue(value);
+ }
+ }
+
+ writeStruct(struct: Struct) {
+ const keys = Object.keys(struct).filter(key => typeof struct[key] !== 'undefined');
+ keys.sort();
+ this.buffer.push(String.fromCharCode(BinaryType.STRUCT, keys.length));
+ for (const key of keys) {
+ // naked string
+ this.buffer.push(String.fromCharCode(key.length), key);
+ this.writeValue(struct[key]);
+ }
+ }
+ }
+
+ class Reader {
+ private readonly str: string;
+ private pos = 0;
+
+ constructor(data: string) {
+ this.str = data;
+ }
+
+ // eslint-disable-next-line consistent-return
+ readValue(): Value {
+ // eslint-disable-next-line default-case
+ switch (this.str.charCodeAt(this.pos) as BinaryType) {
+ case BinaryType.STRING:
+ return this.readString();
+ case BinaryType.NUMBER:
+ return this.readNumber();
+ case BinaryType.BOOLEAN_TRUE:
+ this.pos++;
+ return true;
+ case BinaryType.BOOLEAN_FALSE:
+ this.pos++;
+ return false;
+ case BinaryType.LIST:
+ return this.readList();
+ case BinaryType.STRUCT:
+ return this.readStruct();
+ }
+ }
+
+ readString(): string {
+ this.readType(BinaryType.STRING);
+ return this.readNakedString();
+ }
+
+ readNumber(): number {
+ this.readType(BinaryType.NUMBER);
+ for (let i = 0; i < 4; i++) {
+ numberBuffer[i] = this.read();
+ }
+ return new DataView(numberBuffer.buffer).getFloat64(0);
+ }
+
+ readBoolean(): boolean {
+ const type = this.read();
+ if (type === BinaryType.BOOLEAN_TRUE) return true;
+ if (type === BinaryType.BOOLEAN_FALSE) return false;
+ throw new Error(
+ `Expected type ${BinaryType.BOOLEAN_TRUE}|${BinaryType.BOOLEAN_FALSE} found $type} at pos ${this.pos - 1}`,
+ );
+ }
+
+ readList(): List {
+ this.readType(BinaryType.LIST);
+ const list: Value[] = [];
+ let len = this.read();
+ while (--len >= 0) {
+ list.push(this.readValue());
+ }
+ return list as List;
+ }
+
+ readStruct(): Struct {
+ this.readType(BinaryType.STRUCT);
+ const struct: Record = {};
+ let len = this.read();
+ while (--len >= 0) {
+ const key = this.readNakedString();
+ const value = this.readValue();
+ struct[key] = value;
+ }
+ return struct;
+ }
+
+ private read(): number {
+ if (this.pos >= this.str.length) throw new Error('Read past end of data');
+ return this.str.charCodeAt(this.pos++);
+ }
+ private readType(expectedType: BinaryType) {
+ const actualType = this.read();
+ if (actualType !== expectedType)
+ throw new Error(`Expected type ${expectedType} found ${actualType} at pos ${this.pos - 1}`);
+ }
+ private readNakedString(): string {
+ const len = this.read();
+ const start = this.pos;
+ this.pos += len;
+ if (this.pos > this.str.length) throw new Error('Read past end of data');
+ return this.str.substring(start, this.pos);
+ }
+ }
}
// eslint-disable-next-line @typescript-eslint/no-redeclare
export type Value = Value.Primitive | Value.Struct | Value.List | undefined;
diff --git a/packages/sdk/src/index.ts b/packages/sdk/src/index.ts
index 5ab7d79f..f0757976 100644
--- a/packages/sdk/src/index.ts
+++ b/packages/sdk/src/index.ts
@@ -4,3 +4,5 @@ export * from './flags';
export * from './Confidence';
export * from './Value';
export * from './trackers';
+export * from './Trackable';
+export * from './Closer';
diff --git a/tsconfig.json b/tsconfig.json
index ff80b2f1..0be2cc67 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -4,6 +4,7 @@
"references": [
{ "path": "./packages/sdk" },
{ "path": "./packages/openfeature-server-provider" },
- { "path": "./packages/openfeature-web-provider" }
+ { "path": "./packages/openfeature-web-provider" },
+ { "path": "./packages/react" }
]
}
diff --git a/yarn.lock b/yarn.lock
index 0b78e26e..413e6766 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -3147,16 +3147,6 @@ __metadata:
languageName: node
linkType: hard
-"@openfeature/react-sdk@npm:^0.3.3":
- version: 0.3.3
- resolution: "@openfeature/react-sdk@npm:0.3.3"
- peerDependencies:
- "@openfeature/web-sdk": ^1.0.2
- react: ">=16.8.0"
- checksum: 10c0/c8ee7b241db6087253e08ab98eb0a36872b31ce75c4c3b48f7398c8856fd01b7223dcd7e1bae14a9f9b9647c9d9f519243514739be08d93187979b691d9775cd
- languageName: node
- linkType: hard
-
"@openfeature/server-sdk@npm:^1.13.5":
version: 1.13.5
resolution: "@openfeature/server-sdk@npm:1.13.5"
@@ -3635,7 +3625,7 @@ __metadata:
languageName: unknown
linkType: soft
-"@spotify-confidence/openfeature-web-provider@npm:0.2.5, @spotify-confidence/openfeature-web-provider@workspace:packages/openfeature-web-provider":
+"@spotify-confidence/openfeature-web-provider@workspace:packages/openfeature-web-provider":
version: 0.0.0-use.local
resolution: "@spotify-confidence/openfeature-web-provider@workspace:packages/openfeature-web-provider"
dependencies:
@@ -3650,6 +3640,20 @@ __metadata:
languageName: unknown
linkType: soft
+"@spotify-confidence/react@npm:0.0.1, @spotify-confidence/react@workspace:packages/react":
+ version: 0.0.0-use.local
+ resolution: "@spotify-confidence/react@workspace:packages/react"
+ dependencies:
+ "@microsoft/api-extractor": "npm:*"
+ "@spotify-confidence/sdk": "npm:0.0.7"
+ react: "npm:^18.2.0"
+ rollup: "npm:*"
+ peerDependencies:
+ "@spotify-confidence/sdk": 0.0.7
+ react: ^18.2.0
+ languageName: unknown
+ linkType: soft
+
"@spotify-confidence/sdk@npm:0.0.7, @spotify-confidence/sdk@workspace:packages/sdk":
version: 0.0.0-use.local
resolution: "@spotify-confidence/sdk@workspace:packages/sdk"
@@ -4538,17 +4542,6 @@ __metadata:
languageName: node
linkType: hard
-"@types/react@npm:^18.0.0":
- version: 18.2.28
- resolution: "@types/react@npm:18.2.28"
- dependencies:
- "@types/prop-types": "npm:*"
- "@types/scheduler": "npm:*"
- csstype: "npm:^3.0.2"
- checksum: 10c0/7bde71a9f5ba1feef7762b3a9280f3fc9dfba6ea905cbcb73f7e9cd55adcf69148d77e53da328c892767dc218dfb7319bf00a94f6550c1b58019b756cb27207d
- languageName: node
- linkType: hard
-
"@types/resolve@npm:1.17.1":
version: 1.17.1
resolution: "@types/resolve@npm:1.17.1"
@@ -14729,10 +14722,7 @@ __metadata:
"@babel/plugin-syntax-flow": "npm:^7.22.5"
"@babel/plugin-transform-private-property-in-object": "npm:^7.22.11"
"@babel/plugin-transform-react-jsx": "npm:^7.22.15"
- "@openfeature/core": "npm:^1.1.0"
- "@openfeature/react-sdk": "npm:^0.3.3"
- "@openfeature/web-sdk": "npm:^1.0.3"
- "@spotify-confidence/openfeature-web-provider": "npm:0.2.5"
+ "@spotify-confidence/react": "npm:0.0.1"
"@spotify-confidence/sdk": "npm:0.0.7"
"@testing-library/dom": "npm:^7.29.6"
"@testing-library/jest-dom": "npm:^5.14.1"
@@ -14740,8 +14730,6 @@ __metadata:
"@testing-library/user-event": "npm:^13.2.1"
"@types/jest": "npm:^27.0.1"
"@types/node": "npm:^16.7.13"
- "@types/react": "npm:^18.0.0"
- "@types/react-dom": "npm:^18.0.0"
react: "npm:^18.2.0"
react-dom: "npm:^18.2.0"
react-scripts: "npm:5.0.1"