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}

+ ); }; 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"