Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
186 changes: 186 additions & 0 deletions packages/core/src/flags/FlagsClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,23 @@ import { InternalLog } from '../InternalLog';
import { SdkVerbosity } from '../config/types/SdkVerbosity';
import type { DdNativeFlagsType } from '../nativeModulesTypes';

import {
contextMatchesConfiguration,
decodePrecomputedFlags,
normalizeWireContext
} from './configuration';
import type { ParsedFlagsConfiguration } from './configuration';
import { processEvaluationContext } from './internal';
import type { FlagCacheEntry } from './internal';
import type { JsonValue, EvaluationContext, FlagDetails } from './types';

/**
* Tracks how a configuration supplied via {@link FlagsClient.setConfiguration} relates
* to the active evaluation context. `'none'` means no offline configuration is engaged
* (the online/fetch path is in effect).
*/
type ConfigurationStatus = 'none' | 'ready' | 'mismatch' | 'invalid';

export class FlagsClient {
// eslint-disable-next-line global-require, @typescript-eslint/no-var-requires
private nativeFlags: DdNativeFlagsType = require('../specs/NativeDdFlags')
Expand All @@ -23,6 +36,12 @@ export class FlagsClient {

private flagsCache: Record<string, FlagCacheEntry> = {};

private loadedConfiguration:
| ParsedFlagsConfiguration
| undefined = undefined;

private configurationStatus: ConfigurationStatus = 'none';

constructor(clientName: string = 'default') {
this.clientName = clientName;
}
Expand Down Expand Up @@ -65,7 +84,21 @@ export class FlagsClient {

this.evaluationContext = processedContext;
this.flagsCache = result;

// An explicit online fetch supersedes any previously loaded offline
// configuration, so drop the offline overlay to keep state coherent.
//
// PROVISIONAL: this reflects the current fetch-always default. When the
// `NEVER` fetch policy lands, that path must skip the fetch and re-run
// `applyConfiguration()` against the new context instead of dropping the
// loaded configuration.
this.loadedConfiguration = undefined;
this.configurationStatus = 'none';
} catch (error) {
// NOTE: a failed fetch leaves any previously loaded offline configuration in
// place, so the client may keep serving it (and attribute exposures to its
// context). Fetch-failure/staleness fallback is deferred to the fetch-policy
// step.
if (error instanceof Error) {
InternalLog.log(
`Error setting flag evaluation context: ${error.message}`,
Expand All @@ -77,6 +110,136 @@ export class FlagsClient {
}
};

/**
* Set the evaluation context **without** fetching a configuration from the network,
* then reconcile any configuration loaded via {@link setConfiguration} against it.
*
* This is the offline counterpart to {@link setEvaluationContext}: it records the
* active context and re-evaluates the loaded configuration (context matching, for a
* precomputed configuration) with no native request. It is intended for offline
* providers that own their configuration via `setConfiguration` and must not fetch on
* a context change. With no configuration loaded yet, the context is simply recorded.
*
* @param context The evaluation context to associate with the current client.
*/
setEvaluationContextWithoutFetching = (
context: EvaluationContext
): void => {
this.evaluationContext = processEvaluationContext(context);

// Re-evaluate a loaded offline configuration against the new context. Readiness
// when no configuration is loaded yet is the provider's concern.
if (this.loadedConfiguration) {
this.applyConfiguration();
}
};
Comment on lines +125 to +135

/**
* Load a configuration (parsed from a `ConfigurationWire` string via
* `configurationFromString`) into the client for offline evaluation.
*
* For a precomputed configuration this populates the flag cache and, when no context
* has been set yet, adopts the configuration's embedded evaluation context — **no
* network request is made**. If a context has already been set, the configuration's
* context must match it; otherwise the client serves no values and reports
* `INVALID_CONTEXT`.
*
* @param configuration The configuration to load.
*
* @example
* ```ts
* const configuration = configurationFromString(wire);
* flagsClient.setConfiguration(configuration);
*
* const value = flagsClient.getBooleanValue('new-feature', false);
* ```
*/
setConfiguration = (configuration: ParsedFlagsConfiguration): void => {
this.loadedConfiguration = configuration;
this.applyConfiguration();
};

/**
* Reconcile the loaded configuration against the active evaluation context and
* (re)compute the servable flag cache and configuration status.
*/
private applyConfiguration = (): void => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As mentioned on the docs PR here: #1326 (comment)

Not propagating the Flags configuration to the native SDK underneath effectively means that offline init does not support hybrid apps, as apps that fetch the Flags config from the native side won't have the same configuration as the JS layer. This introduces an inconsistency that at the very least needs to be accounted and acknowledged for, especially if the initial implementation won't rely on any changes coming from the native SDKs.

const precomputed = this.loadedConfiguration?.precomputed;

// Only precomputed configurations are supported for now. An empty configuration
// (an invalid/failed wire parse, or a server-only wire) is not usable.
//
// FORWARD-COMPAT SEAM: when the `server`/rules branch is parsed (see
// `ParsedFlagsConfiguration`), it must be handled BEFORE this guard — a rules
// configuration is context-agnostic and must NOT be rejected here as `invalid`.
if (!precomputed) {
this.flagsCache = {};
this.configurationStatus = 'invalid';
InternalLog.log(
`No usable precomputed configuration was provided for '${this.clientName}'.`,
SdkVerbosity.WARN
);
return;
}

let decoded: Record<string, FlagCacheEntry>;
try {
decoded = decodePrecomputedFlags(precomputed.response);
} catch (error) {
this.flagsCache = {};
this.configurationStatus = 'invalid';
if (error instanceof Error) {
InternalLog.log(
`Unsupported flags configuration for '${this.clientName}': ${error.message}`,
SdkVerbosity.WARN
);
}
return;
}
Comment on lines +185 to +198

// If no context has been set yet, adopt the configuration's embedded context
// (implicit set — no native fetch). A context-agnostic configuration falls back
// to an empty context so evaluation can proceed.
if (!this.evaluationContext) {
if (precomputed.context) {
this.evaluationContext = normalizeWireContext(
precomputed.context
);
} else {
InternalLog.log(
`The provided configuration for '${this.clientName}' has no embedded context; treating it as context-agnostic.`,
SdkVerbosity.WARN
);
this.evaluationContext = { targetingKey: '', attributes: {} };
}

this.flagsCache = decoded;
this.configurationStatus = 'ready';
return;
}

// A context is already set — the configuration must match it.
if (
contextMatchesConfiguration(
precomputed.context,
this.evaluationContext
)
) {
this.flagsCache = decoded;
this.configurationStatus = 'ready';
} else {
// Per spec, a context mismatch must not serve values. This also blocks a
// previously-fetched online cache until a matching config or a new fetch;
// the fetch policy (a later step) formalizes online/offline precedence.
this.flagsCache = {};
this.configurationStatus = 'mismatch';
InternalLog.log(
`The provided configuration for '${this.clientName}' does not match the active evaluation context.`,
SdkVerbosity.WARN
);
}
};

private track = (flag: FlagCacheEntry, context: EvaluationContext) => {
// A non-blocking call; don't await this.
this.nativeFlags
Expand All @@ -102,6 +265,29 @@ export class FlagsClient {
defaultValue: T,
type: 'boolean' | 'string' | 'number' | 'object'
): FlagDetails<T> => {
if (this.configurationStatus === 'mismatch') {
return {
key,
value: defaultValue,
reason: 'ERROR',
errorCode: 'INVALID_CONTEXT',
errorMessage: `The loaded configuration for '${this.clientName}' does not match the active evaluation context.`
};
}

// A loaded-but-unusable configuration surfaces as PROVIDER_NOT_READY at the
// evaluation layer (distinct from FLAG_NOT_FOUND). The dedicated PROVIDER_ERROR
// provider event is wired by the OpenFeature provider in a later step.
if (this.configurationStatus === 'invalid') {
return {
key,
value: defaultValue,
reason: 'ERROR',
errorCode: 'PROVIDER_NOT_READY',
errorMessage: `The loaded configuration for '${this.clientName}' is not usable. Provide a valid precomputed configuration.`
};
}

if (!this.evaluationContext) {
return {
key,
Expand Down
Loading