Skip to content
6 changes: 6 additions & 0 deletions packages/common_client/lib/launchdarkly_common_client.dart
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,12 @@ export 'src/config/common_platform.dart' show CommonPlatform;
export 'src/config/events_config.dart' show EventsConfig;
export 'src/config/credential/credential_source.dart' show CredentialSource;
export 'src/connection_mode.dart' show ConnectionMode;
export 'src/data_sources/fdv2/mode_resolution.dart'
show
ModeState,
ModeResolutionEntry,
resolveConnectionMode,
flutterDefaultResolutionTable;
export 'src/data_sources/data_source_status.dart'
show DataSourceStatusErrorInfo, DataSourceStatus, DataSourceState;

Expand Down
4 changes: 4 additions & 0 deletions packages/common_client/lib/src/connection_mode.dart
Original file line number Diff line number Diff line change
Expand Up @@ -12,4 +12,8 @@ enum ConnectionMode {

/// The SDK will make periodic requests to receive updates from LaunchDarkly.
polling,

/// The SDK is in a background state and will use the configured background
/// connection mode or default for the platform if not configured.
background,
}
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ final class DataSourceManager {
_statusManager.setOffline();
case ConnectionMode.streaming:
case ConnectionMode.polling:
case ConnectionMode.background:
// default:
// We may want to consider adding another state to the data source state
// for the intermediate between switching data sources, or for identifying
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import 'mode_definition.dart';

/// Built-in [ModeDefinition] values.
abstract final class BuiltInModes {
BuiltInModes._();

/// Default foreground poll interval.
static const Duration _foregroundPollInterval = Duration(seconds: 300);

/// Default background poll interval.
static const Duration _backgroundPollInterval = Duration(seconds: 3600);

/// Default streaming mode (mobile foreground / desktop).
static const ModeDefinition streaming = ModeDefinition(
initializers: [
CacheInitializer(),
PollingInitializer(),
],
synchronizers: [
StreamingSynchronizer(),
PollingSynchronizer(),
],
fdv1Fallback: Fdv1FallbackConfig(
pollInterval: _foregroundPollInterval,
),
);

/// Polling-only mode.
static const ModeDefinition polling = ModeDefinition(
initializers: [CacheInitializer()],
synchronizers: [PollingSynchronizer()],
fdv1Fallback: Fdv1FallbackConfig(
pollInterval: _foregroundPollInterval,
),
);

/// Offline: cache initializer only; no synchronizers.
static const ModeDefinition offline = ModeDefinition(
initializers: [CacheInitializer()],
synchronizers: [],
);

/// Mobile background: cache initializer, reduced-rate polling synchronizer (CSFDV2 §5.2.3).
static const ModeDefinition background = ModeDefinition(
initializers: [CacheInitializer()],
synchronizers: [
PollingSynchronizer(pollInterval: _backgroundPollInterval),
],
fdv1Fallback: Fdv1FallbackConfig(
pollInterval: _backgroundPollInterval,
),
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart';

import 'flag_eval_mapper.dart';
import 'payload.dart';
import 'selector.dart';
import 'source.dart';
import 'source_result.dart';

/// The shape of a cache hit: parsed evaluation results plus the
/// environment ID that was current when the cache was written.
typedef CachedFlags = ({
Map<String, LDEvaluationResult> flags,
String? environmentId,
});

/// Reads cached flag state for [context] from persistence. Returns
/// null on a cache miss, an unreadable entry, or a parse failure.
typedef CachedFlagsReader = Future<CachedFlags?> Function(LDContext context);

/// One-shot initializer that brings the SDK up from its persistence
/// cache. The cache is read once; retries are not meaningful for a
/// local read.
///
/// On cache hit, emits a [ChangeSetResult] with `persist: false`
/// (the data is already cached) and an empty selector (the cache
/// does not track server-side selector state).
/// The payload type is [PayloadType.full]: a cache load is a complete
/// snapshot, not a delta.
///
/// On cache miss, emits a [ChangeSetResult] with [PayloadType.none] so
/// the initializer chain advances rather than terminating. The cache
/// is best-effort, not a source of truth.
final class CacheInitializer implements Initializer {
final CachedFlagsReader _reader;
final LDContext _context;
final LDLogger _logger;
final DateTime Function() _now;

bool _closed = false;

CacheInitializer({
required CachedFlagsReader reader,
required LDContext context,
required LDLogger logger,
DateTime Function()? now,
}) : _reader = reader,
_context = context,
_logger = logger.subLogger('CacheInitializer'),
_now = now ?? DateTime.now;

@override
Future<FDv2SourceResult> run() async {
if (_closed) return _shutdown();

final CachedFlags? cached;
try {
cached = await _reader(_context);
} catch (err) {
_logger.warn('Cache read failed (${err.runtimeType}); '
'treating as miss');
return _miss();
}

if (_closed) return _shutdown();

if (cached == null) {
return _miss();
}

final updates = <Update>[];
cached.flags.forEach((key, evalResult) {
updates.add(Update(
kind: flagEvalKind,
key: key,
version: evalResult.version,
object: LDEvaluationResultSerialization.toJson(evalResult),
));
});

return ChangeSetResult(
payload: Payload(
type: PayloadType.full,
selector: Selector.empty,
updates: updates,
),
environmentId: cached.environmentId,
freshness: _now(),
persist: false,
);
}

@override
void close() {
_closed = true;
}

ChangeSetResult _miss() => ChangeSetResult(
payload: const Payload(
type: PayloadType.none,
updates: [],
),
freshness: _now(),
persist: false,
);

StatusResult _shutdown() => FDv2SourceResults.shutdown(
message: 'Cache initializer closed before completion',
);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
/// Computes how long to wait before the next poll, given when the SDK
/// last received a fresh response and the configured polling interval.
///
/// Returns the time remaining in the interval relative to [freshness].
/// If [freshness] is null (no successful poll yet) returns the full interval.
/// If [freshness] is older than the interval (we're overdue), returns zero.
///
/// Caps the returned delay at [interval] so a freshness timestamp from
/// the future (clock skew, manually adjusted system time) cannot push
/// the next poll arbitrarily far out.
Duration calculatePollDelay({
required DateTime now,
required Duration interval,
DateTime? freshness,
}) {
if (freshness == null) {
return interval;
}
final elapsed = now.difference(freshness);
if (elapsed.isNegative) {
return interval;
}
if (elapsed >= interval) {
return Duration.zero;
}
return interval - elapsed;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,175 @@
import 'dart:convert';

import 'package:launchdarkly_dart_common/launchdarkly_dart_common.dart'
hide ServiceEndpoints;

import '../../config/service_endpoints.dart';
import 'cache_initializer.dart' as cache_src;
import 'source_factory_context.dart';
import 'mode_definition.dart' as mode;
import 'polling_base.dart';
import 'polling_initializer.dart';
import 'polling_synchronizer.dart';
import 'requestor.dart';
import 'selector.dart';
import 'source.dart';

/// Merges optional per-entry [mode.EndpointConfig] overrides into [base].
ServiceEndpoints mergeServiceEndpoints(
ServiceEndpoints base,
mode.EndpointConfig? override,
) {
if (override == null) {
return base;
}
if (override.pollingBaseUri == null && override.streamingBaseUri == null) {
return base;
}
return ServiceEndpoints.custom(
polling: override.pollingBaseUri?.toString() ?? base.polling,
streaming: override.streamingBaseUri?.toString() ?? base.streaming,
events: base.events,
);
}

FDv2PollingBase _sharedPollingBase({
required mode.EndpointConfig? endpoints,
required bool usePost,
required SourceFactoryContext ctx,
}) {
final endpointsResolved =
mergeServiceEndpoints(ctx.serviceEndpoints, endpoints);
final requestor = FDv2Requestor(
logger: ctx.logger,
endpoints: endpointsResolved,
contextEncoded: base64UrlEncode(utf8.encode(ctx.contextJson)),
contextJson: ctx.contextJson,
usePost: usePost,
withReasons: ctx.withReasons,
httpProperties: ctx.httpProperties,
httpClientFactory: ctx.httpClientFactory ?? _defaultHttpClientFactory,
);
return FDv2PollingBase(
logger: ctx.logger,
requestor: requestor,
);
}

HttpClient _defaultHttpClientFactory(HttpProperties httpProperties) {
return HttpClient(httpProperties: httpProperties);
}

/// A factory for creating [Initializer] instances.
final class InitializerFactory {
/// True for cache initializers ([CONNMODE] / CSFDv2 cache-miss success rule).
final bool isCache;

final Initializer Function(SelectorGetter selectorGetter) _create;

InitializerFactory({
required Initializer Function(SelectorGetter selectorGetter) create,
this.isCache = false,
}) : _create = create;

/// Returns a **new** [Initializer] bound to [selectorGetter] (or ignores it
/// for cache, matching JS).
Initializer create(SelectorGetter selectorGetter) => _create(selectorGetter);
}

/// A factory for creating [Synchronizer] instances.
final class SynchronizerFactory {
final Synchronizer Function(SelectorGetter selectorGetter) _create;

SynchronizerFactory({
required Synchronizer Function(SelectorGetter selectorGetter) create,
}) : _create = create;

Synchronizer create(SelectorGetter selectorGetter) => _create(selectorGetter);
}

/// Builds an [InitializerFactory] for a single [mode.InitializerEntry].
///
/// Throws [UnsupportedError] for unsupported entry types.
InitializerFactory createInitializerFactoryFromEntry(
mode.InitializerEntry entry,
SourceFactoryContext ctx,
) {
switch (entry) {
case mode.CacheInitializer():
return InitializerFactory(
isCache: true,
create: (_) => cache_src.CacheInitializer(
reader: ctx.cachedFlagsReader,
context: ctx.context,
logger: ctx.logger,
),
);
case final mode.PollingInitializer e:
final base = _sharedPollingBase(
endpoints: e.endpoints,
usePost: e.usePost,
ctx: ctx,
);
return InitializerFactory(
create: (SelectorGetter selectorGetter) => FDv2PollingInitializer(
poll: ({Selector basis = Selector.empty}) =>
base.pollOnce(basis: basis),
selectorGetter: selectorGetter,
logger: ctx.logger,
),
);
case mode.StreamingInitializer():
throw UnsupportedError(
'FDv2 StreamingInitializer factories are not implemented yet',
);
}
}

/// Builds a [SynchronizerFactory] for a single [mode.SynchronizerEntry].
///
/// Throws [UnsupportedError] for unsupported entry types.
SynchronizerFactory createSynchronizerFactoryFromEntry(
mode.SynchronizerEntry entry,
SourceFactoryContext ctx,
) {
switch (entry) {
case final mode.PollingSynchronizer e:
final base = _sharedPollingBase(
endpoints: e.endpoints,
usePost: e.usePost,
ctx: ctx,
);
final interval = e.pollInterval ?? ctx.defaultPollingInterval;
return SynchronizerFactory(
create: (SelectorGetter selectorGetter) => FDv2PollingSynchronizer(
poll: ({Selector basis = Selector.empty}) =>
base.pollOnce(basis: basis),
selectorGetter: selectorGetter,
interval: interval,
logger: ctx.logger,
),
);
case mode.StreamingSynchronizer():
throw UnsupportedError(
'FDv2 StreamingSynchronizer factories are not implemented yet',
);
}
}

/// One factory per entry, in list order.
List<InitializerFactory> buildInitializerFactories(
List<mode.InitializerEntry> entries,
SourceFactoryContext ctx,
) {
return entries.map((e) => createInitializerFactoryFromEntry(e, ctx)).toList();
}

/// One factory per entry, in list order.
List<SynchronizerFactory> buildSynchronizerFactories(
List<mode.SynchronizerEntry> entries,
SourceFactoryContext ctx,
) {
return entries
.map((e) => createSynchronizerFactoryFromEntry(e, ctx))
.toList();
}
Loading
Loading