@launchdarkly/js-client-sdk-common 1.22.0 → 1.24.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/CHANGELOG.md +40 -0
  2. package/dist/cjs/DataManager.d.ts +33 -0
  3. package/dist/cjs/DataManager.d.ts.map +1 -1
  4. package/dist/cjs/api/LDOptions.d.ts +17 -5
  5. package/dist/cjs/api/LDOptions.d.ts.map +1 -1
  6. package/dist/cjs/api/datasource/DataSourceEntry.d.ts +57 -0
  7. package/dist/cjs/api/datasource/DataSourceEntry.d.ts.map +1 -1
  8. package/dist/cjs/api/datasource/FDv2ConnectionMode.d.ts +5 -0
  9. package/dist/cjs/api/datasource/FDv2ConnectionMode.d.ts.map +1 -1
  10. package/dist/cjs/api/datasource/LDClientDataSystemOptions.d.ts +55 -37
  11. package/dist/cjs/api/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  12. package/dist/cjs/api/datasource/ModeDefinition.d.ts +19 -3
  13. package/dist/cjs/api/datasource/ModeDefinition.d.ts.map +1 -1
  14. package/dist/cjs/api/datasource/index.d.ts +2 -2
  15. package/dist/cjs/api/datasource/index.d.ts.map +1 -1
  16. package/dist/cjs/configuration/Configuration.d.ts +3 -3
  17. package/dist/cjs/configuration/Configuration.d.ts.map +1 -1
  18. package/dist/cjs/configuration/validateOptions.d.ts +18 -2
  19. package/dist/cjs/configuration/validateOptions.d.ts.map +1 -1
  20. package/dist/cjs/configuration/validators.d.ts +1 -1
  21. package/dist/cjs/configuration/validators.d.ts.map +1 -1
  22. package/dist/cjs/datasource/ConnectionModeConfig.d.ts +4 -2
  23. package/dist/cjs/datasource/ConnectionModeConfig.d.ts.map +1 -1
  24. package/dist/cjs/datasource/FDv2DataManagerBase.d.ts +87 -0
  25. package/dist/cjs/datasource/FDv2DataManagerBase.d.ts.map +1 -0
  26. package/dist/cjs/datasource/LDClientDataSystemOptions.d.ts +38 -3
  27. package/dist/cjs/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  28. package/dist/cjs/datasource/SourceFactoryProvider.d.ts +77 -0
  29. package/dist/cjs/datasource/SourceFactoryProvider.d.ts.map +1 -0
  30. package/dist/cjs/datasource/fdv2/FDv2DataSource.d.ts.map +1 -1
  31. package/dist/cjs/flag-manager/FlagManager.d.ts +16 -1
  32. package/dist/cjs/flag-manager/FlagManager.d.ts.map +1 -1
  33. package/dist/cjs/flag-manager/FlagPersistence.d.ts +13 -1
  34. package/dist/cjs/flag-manager/FlagPersistence.d.ts.map +1 -1
  35. package/dist/cjs/flag-manager/FlagStore.d.ts +10 -0
  36. package/dist/cjs/flag-manager/FlagStore.d.ts.map +1 -1
  37. package/dist/cjs/flag-manager/FlagUpdater.d.ts +13 -1
  38. package/dist/cjs/flag-manager/FlagUpdater.d.ts.map +1 -1
  39. package/dist/cjs/index.cjs +2271 -12
  40. package/dist/cjs/index.cjs.map +1 -1
  41. package/dist/cjs/index.d.ts +12 -2
  42. package/dist/cjs/index.d.ts.map +1 -1
  43. package/dist/cjs/types/index.d.ts +2 -2
  44. package/dist/esm/DataManager.d.ts +33 -0
  45. package/dist/esm/DataManager.d.ts.map +1 -1
  46. package/dist/esm/api/LDOptions.d.ts +17 -5
  47. package/dist/esm/api/LDOptions.d.ts.map +1 -1
  48. package/dist/esm/api/datasource/DataSourceEntry.d.ts +57 -0
  49. package/dist/esm/api/datasource/DataSourceEntry.d.ts.map +1 -1
  50. package/dist/esm/api/datasource/FDv2ConnectionMode.d.ts +5 -0
  51. package/dist/esm/api/datasource/FDv2ConnectionMode.d.ts.map +1 -1
  52. package/dist/esm/api/datasource/LDClientDataSystemOptions.d.ts +55 -37
  53. package/dist/esm/api/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  54. package/dist/esm/api/datasource/ModeDefinition.d.ts +19 -3
  55. package/dist/esm/api/datasource/ModeDefinition.d.ts.map +1 -1
  56. package/dist/esm/api/datasource/index.d.ts +2 -2
  57. package/dist/esm/api/datasource/index.d.ts.map +1 -1
  58. package/dist/esm/configuration/Configuration.d.ts +3 -3
  59. package/dist/esm/configuration/Configuration.d.ts.map +1 -1
  60. package/dist/esm/configuration/validateOptions.d.ts +18 -2
  61. package/dist/esm/configuration/validateOptions.d.ts.map +1 -1
  62. package/dist/esm/configuration/validators.d.ts +1 -1
  63. package/dist/esm/configuration/validators.d.ts.map +1 -1
  64. package/dist/esm/datasource/ConnectionModeConfig.d.ts +4 -2
  65. package/dist/esm/datasource/ConnectionModeConfig.d.ts.map +1 -1
  66. package/dist/esm/datasource/FDv2DataManagerBase.d.ts +87 -0
  67. package/dist/esm/datasource/FDv2DataManagerBase.d.ts.map +1 -0
  68. package/dist/esm/datasource/LDClientDataSystemOptions.d.ts +38 -3
  69. package/dist/esm/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  70. package/dist/esm/datasource/SourceFactoryProvider.d.ts +77 -0
  71. package/dist/esm/datasource/SourceFactoryProvider.d.ts.map +1 -0
  72. package/dist/esm/datasource/fdv2/FDv2DataSource.d.ts.map +1 -1
  73. package/dist/esm/flag-manager/FlagManager.d.ts +16 -1
  74. package/dist/esm/flag-manager/FlagManager.d.ts.map +1 -1
  75. package/dist/esm/flag-manager/FlagPersistence.d.ts +13 -1
  76. package/dist/esm/flag-manager/FlagPersistence.d.ts.map +1 -1
  77. package/dist/esm/flag-manager/FlagStore.d.ts +10 -0
  78. package/dist/esm/flag-manager/FlagStore.d.ts.map +1 -1
  79. package/dist/esm/flag-manager/FlagUpdater.d.ts +13 -1
  80. package/dist/esm/flag-manager/FlagUpdater.d.ts.map +1 -1
  81. package/dist/esm/index.d.ts +12 -2
  82. package/dist/esm/index.d.ts.map +1 -1
  83. package/dist/esm/index.mjs +2267 -14
  84. package/dist/esm/index.mjs.map +1 -1
  85. package/dist/esm/types/index.d.ts +2 -2
  86. package/package.json +2 -2
@@ -1,4 +1,4 @@
1
- import { getPollingUri, isNullish, TypeValidators, OptionMessages, NumberWithMinimum, createSafeLogger, ServiceEndpoints, ApplicationTags, SafeLogger, internal, deepCompact, clone, secondsToMillis, ClientContext, fastDeepEqual, defaultHeaders, Context, LDTimeoutError, AutoEnvAttributes, cancelableTimedPromise, LDClientError, base64UrlEncode, isHttpRecoverable, httpErrorMessage, LDPollingError, DataSourceErrorKind, getStreamingUri, shouldRetry, LDStreamingError } from '@launchdarkly/js-sdk-common';
1
+ import { getPollingUri, isNullish, TypeValidators, OptionMessages, NumberWithMinimum, createSafeLogger, ServiceEndpoints, ApplicationTags, SafeLogger, internal, deepCompact, clone, secondsToMillis, ClientContext, fastDeepEqual, defaultHeaders, Context, LDTimeoutError, AutoEnvAttributes, cancelableTimedPromise, LDClientError, base64UrlEncode, isHttpRecoverable, httpErrorMessage, LDPollingError, DataSourceErrorKind, getStreamingUri, shouldRetry, LDStreamingError, sleep } from '@launchdarkly/js-sdk-common';
2
2
  export * from '@launchdarkly/js-sdk-common';
3
3
  import * as jsSdkCommon from '@launchdarkly/js-sdk-common';
4
4
  export { jsSdkCommon as platform };
@@ -275,10 +275,18 @@ function validateOptions(input, validatorMap, defaults, logger, prefix) {
275
275
  * Creates a validator for nested objects. When used in a validator map,
276
276
  * `validateOptions` will recursively validate the nested object's properties.
277
277
  * Defaults for nested fields are passed through from the parent.
278
+ *
279
+ * @param validators - Validator map for the nested object's fields.
280
+ * @param options - Optional configuration.
281
+ * @param options.defaults - Built-in defaults for nested fields.
282
+ * @param options.is - Custom `is` predicate. When provided, replaces the
283
+ * default "is object" check. Use this to discriminate between object shapes
284
+ * in an `anyOf` (e.g., matching on a `type` discriminant field).
278
285
  */
279
- function validatorOf(validators, builtInDefaults) {
286
+ function validatorOf(validators, options) {
287
+ const builtInDefaults = options?.defaults;
280
288
  return {
281
- is: (u) => TypeValidators.Object.is(u),
289
+ is: options?.is ?? ((u) => TypeValidators.Object.is(u)),
282
290
  getType: () => 'object',
283
291
  validate(value, name, logger, defaults) {
284
292
  if (!TypeValidators.Object.is(value)) {
@@ -292,6 +300,62 @@ function validatorOf(validators, builtInDefaults) {
292
300
  },
293
301
  };
294
302
  }
303
+ /**
304
+ * Creates a validator for arrays of discriminated objects. Each item in the
305
+ * array must be an object containing a `discriminant` field whose value
306
+ * selects which validator map to apply. The valid discriminant values are
307
+ * the keys of `validatorsByType`. Items that are not objects, or whose
308
+ * discriminant value is missing or unrecognized, are filtered out with a
309
+ * warning.
310
+ *
311
+ * @param discriminant - The field name used to determine each item's type.
312
+ * @param validatorsByType - A mapping from discriminant values to the
313
+ * validator maps used to validate items of that type. Each validator map
314
+ * should include a validator for the discriminant field itself.
315
+ *
316
+ * @example
317
+ * ```ts
318
+ * // Validates an array like:
319
+ * // [{ type: 'polling', pollInterval: 60 }, { type: 'cache' }]
320
+ *
321
+ * const validator = arrayOf('type', {
322
+ * cache: { type: TypeValidators.String },
323
+ * polling: { type: TypeValidators.String, pollInterval: TypeValidators.numberWithMin(30) },
324
+ * streaming: { type: TypeValidators.String, initialReconnectDelay: TypeValidators.numberWithMin(1) },
325
+ * });
326
+ * ```
327
+ */
328
+ function arrayOf(discriminant, validatorsByType) {
329
+ return {
330
+ is: (u) => Array.isArray(u),
331
+ getType: () => 'array',
332
+ validate(value, name, logger) {
333
+ if (!Array.isArray(value)) {
334
+ logger?.warn(OptionMessages.wrongOptionType(name, 'array', typeof value));
335
+ return undefined;
336
+ }
337
+ const results = [];
338
+ value.forEach((item, i) => {
339
+ const itemPath = `${name}[${i}]`;
340
+ if (isNullish(item) || !TypeValidators.Object.is(item)) {
341
+ logger?.warn(OptionMessages.wrongOptionType(itemPath, 'object', typeof item));
342
+ return;
343
+ }
344
+ const obj = item;
345
+ const typeValue = obj[discriminant];
346
+ const validators = typeof typeValue === 'string' ? validatorsByType[typeValue] : undefined;
347
+ if (!validators) {
348
+ const expected = Object.keys(validatorsByType).join(' | ');
349
+ const received = typeof typeValue === 'string' ? typeValue : typeof typeValue;
350
+ logger?.warn(OptionMessages.wrongOptionType(`${itemPath}.${discriminant}`, expected, received));
351
+ return;
352
+ }
353
+ results.push(validateOptions(obj, validators, {}, logger, itemPath));
354
+ });
355
+ return { value: results };
356
+ },
357
+ };
358
+ }
295
359
  /**
296
360
  * Creates a validator that tries each provided validator in order and uses the
297
361
  * first one whose `is()` check passes. For compound validators the value is
@@ -321,38 +385,151 @@ function anyOf(...validators) {
321
385
  },
322
386
  };
323
387
  }
388
+ /**
389
+ * Creates a validator for objects with dynamic keys. Each key in the input
390
+ * object is checked against `keyValidator`; unrecognized keys produce a
391
+ * warning. Each value is validated by `valueValidator`. Defaults for
392
+ * individual entries are passed through from the parent so partial overrides
393
+ * preserve non-overridden entries.
394
+ *
395
+ * @param keyValidator - Validates that each key is an allowed value.
396
+ * @param valueValidator - Validates each value in the record.
397
+ * @param options - Optional configuration.
398
+ * @param options.defaults - Built-in defaults for the record entries. When
399
+ * provided, takes priority over defaults passed from the parent at
400
+ * validation time (same precedence as {@link validatorOf}).
401
+ *
402
+ * @example
403
+ * ```ts
404
+ * // Validates a record like { streaming: { ... }, polling: { ... } }
405
+ * // where keys must be valid connection modes:
406
+ * recordOf(
407
+ * TypeValidators.oneOf('streaming', 'polling', 'offline'),
408
+ * validatorOf({ initializers: arrayValidator, synchronizers: arrayValidator }),
409
+ * )
410
+ * ```
411
+ */
412
+ function recordOf(keyValidator, valueValidator, options) {
413
+ const builtInDefaults = options?.defaults;
414
+ return {
415
+ is: (u) => TypeValidators.Object.is(u),
416
+ getType: () => 'object',
417
+ validate(value, name, logger, defaults) {
418
+ if (isNullish(value)) {
419
+ return undefined;
420
+ }
421
+ if (!TypeValidators.Object.is(value)) {
422
+ logger?.warn(OptionMessages.wrongOptionType(name, 'object', typeof value));
423
+ return undefined;
424
+ }
425
+ // Filter invalid keys, then delegate to validateOptions.
426
+ const obj = value;
427
+ const filtered = {};
428
+ const validatorMap = {};
429
+ Object.keys(obj).forEach((key) => {
430
+ if (keyValidator.is(key)) {
431
+ filtered[key] = obj[key];
432
+ validatorMap[key] = valueValidator;
433
+ }
434
+ else {
435
+ logger?.warn(OptionMessages.wrongOptionType(name, keyValidator.getType(), key));
436
+ }
437
+ });
438
+ const recordDefaults = builtInDefaults ??
439
+ (TypeValidators.Object.is(defaults) ? defaults : {});
440
+ return { value: validateOptions(filtered, validatorMap, recordDefaults, logger, name) };
441
+ },
442
+ };
443
+ }
324
444
 
445
+ const DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS = 300;
446
+ const BACKGROUND_POLL_INTERVAL_SECONDS = 3600;
325
447
  const dataSourceTypeValidator = TypeValidators.oneOf('cache', 'polling', 'streaming');
326
448
  const connectionModeValidator = TypeValidators.oneOf('streaming', 'polling', 'offline', 'one-shot', 'background');
327
449
  const endpointValidators = {
328
450
  pollingBaseUri: TypeValidators.String,
329
451
  streamingBaseUri: TypeValidators.String,
330
452
  };
331
- ({
453
+ const cacheEntryValidators = {
454
+ type: dataSourceTypeValidator,
455
+ };
456
+ const pollingEntryValidators = {
332
457
  type: dataSourceTypeValidator,
333
458
  pollInterval: TypeValidators.numberWithMin(30),
334
459
  endpoints: validatorOf(endpointValidators),
335
- });
336
- ({
460
+ };
461
+ const streamingEntryValidators = {
337
462
  type: dataSourceTypeValidator,
338
463
  initialReconnectDelay: TypeValidators.numberWithMin(1),
339
464
  endpoints: validatorOf(endpointValidators),
465
+ };
466
+ const initializerEntryArrayValidator = arrayOf('type', {
467
+ cache: cacheEntryValidators,
468
+ polling: pollingEntryValidators,
469
+ streaming: streamingEntryValidators,
470
+ });
471
+ const synchronizerEntryArrayValidator = arrayOf('type', {
472
+ polling: pollingEntryValidators,
473
+ streaming: streamingEntryValidators,
340
474
  });
475
+ const fdv1FallbackValidators = {
476
+ pollInterval: TypeValidators.numberWithMin(30),
477
+ endpoints: validatorOf(endpointValidators),
478
+ };
479
+ const modeDefinitionValidators = {
480
+ initializers: initializerEntryArrayValidator,
481
+ synchronizers: synchronizerEntryArrayValidator,
482
+ fdv1Fallback: validatorOf(fdv1FallbackValidators),
483
+ };
484
+ const MODE_TABLE = {
485
+ streaming: {
486
+ initializers: [{ type: 'cache' }, { type: 'polling' }],
487
+ synchronizers: [{ type: 'streaming' }, { type: 'polling' }],
488
+ fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
489
+ },
490
+ polling: {
491
+ initializers: [{ type: 'cache' }],
492
+ synchronizers: [{ type: 'polling' }],
493
+ fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
494
+ },
495
+ offline: {
496
+ initializers: [{ type: 'cache' }],
497
+ synchronizers: [],
498
+ },
499
+ 'one-shot': {
500
+ initializers: [{ type: 'cache' }, { type: 'polling' }, { type: 'streaming' }],
501
+ synchronizers: [],
502
+ },
503
+ background: {
504
+ initializers: [{ type: 'cache' }],
505
+ synchronizers: [{ type: 'polling', pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS }],
506
+ fdv1Fallback: { pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS },
507
+ },
508
+ };
509
+ const connectionModesValidator = recordOf(connectionModeValidator, validatorOf(modeDefinitionValidators));
341
510
 
342
- const modeSwitchingValidators = {
511
+ function hasType(u, type) {
512
+ return TypeValidators.Object.is(u) && u.type === type;
513
+ }
514
+ const automaticModeValidators = {
515
+ type: TypeValidators.oneOf('automatic'),
343
516
  lifecycle: TypeValidators.Boolean,
344
517
  network: TypeValidators.Boolean,
345
518
  };
346
- const dataSystemValidators = {
519
+ const manualModeValidators = {
520
+ type: TypeValidators.oneOf('manual'),
347
521
  initialConnectionMode: connectionModeValidator,
522
+ };
523
+ const dataSystemValidators = {
348
524
  backgroundConnectionMode: connectionModeValidator,
349
- automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(modeSwitchingValidators)),
525
+ automaticModeSwitching: anyOf(TypeValidators.Boolean, validatorOf(automaticModeValidators, { is: (u) => hasType(u, 'automatic') }), validatorOf(manualModeValidators, { is: (u) => hasType(u, 'manual') })),
526
+ connectionModes: connectionModesValidator,
350
527
  };
351
528
  /**
352
529
  * Default FDv2 data system configuration for browser SDKs.
353
530
  */
354
531
  const BROWSER_DATA_SYSTEM_DEFAULTS = {
355
- initialConnectionMode: 'one-shot',
532
+ foregroundConnectionMode: 'one-shot',
356
533
  backgroundConnectionMode: undefined,
357
534
  automaticModeSwitching: false,
358
535
  };
@@ -360,7 +537,7 @@ const BROWSER_DATA_SYSTEM_DEFAULTS = {
360
537
  * Default FDv2 data system configuration for mobile (React Native) SDKs.
361
538
  */
362
539
  const MOBILE_DATA_SYSTEM_DEFAULTS = {
363
- initialConnectionMode: 'streaming',
540
+ foregroundConnectionMode: 'streaming',
364
541
  backgroundConnectionMode: 'background',
365
542
  automaticModeSwitching: true,
366
543
  };
@@ -368,10 +545,24 @@ const MOBILE_DATA_SYSTEM_DEFAULTS = {
368
545
  * Default FDv2 data system configuration for desktop SDKs (Electron, etc.).
369
546
  */
370
547
  const DESKTOP_DATA_SYSTEM_DEFAULTS = {
371
- initialConnectionMode: 'streaming',
548
+ foregroundConnectionMode: 'streaming',
372
549
  backgroundConnectionMode: undefined,
373
550
  automaticModeSwitching: false,
374
551
  };
552
+ function isManualModeSwitching(value) {
553
+ return typeof value === 'object' && value !== null && 'type' in value && value.type === 'manual';
554
+ }
555
+ /**
556
+ * Resolve the foreground connection mode from a validated data system config
557
+ * and platform defaults. Uses the mode from `ManualModeSwitching` when present,
558
+ * otherwise falls back to the platform default.
559
+ */
560
+ function resolveForegroundMode(dataSystem, defaults) {
561
+ if (isManualModeSwitching(dataSystem.automaticModeSwitching)) {
562
+ return dataSystem.automaticModeSwitching.initialConnectionMode;
563
+ }
564
+ return dataSystem.foregroundConnectionMode ?? defaults.foregroundConnectionMode;
565
+ }
375
566
 
376
567
  function createValidators(options) {
377
568
  return {
@@ -401,7 +592,12 @@ function createValidators(options) {
401
592
  inspectors: TypeValidators.createTypeArray('LDInspection', {}),
402
593
  cleanOldPersistentData: TypeValidators.Boolean,
403
594
  dataSystem: options?.dataSystemDefaults
404
- ? validatorOf(dataSystemValidators, options.dataSystemDefaults)
595
+ ? validatorOf(dataSystemValidators, {
596
+ defaults: {
597
+ ...options.dataSystemDefaults,
598
+ connectionModes: MODE_TABLE,
599
+ },
600
+ })
405
601
  : TypeValidators.Object,
406
602
  };
407
603
  }
@@ -818,6 +1014,33 @@ async function hashContext(crypto, context) {
818
1014
  }
819
1015
  return digest(crypto.createHash('sha256').update(json), 'base64');
820
1016
  }
1017
+ /**
1018
+ * Reads the freshness timestamp from storage for the given context.
1019
+ *
1020
+ * Returns `undefined` if no freshness record exists, the data is corrupt,
1021
+ * or the context attributes have changed since the freshness was recorded.
1022
+ */
1023
+ async function readFreshness(storage, crypto, environmentNamespace, context, logger) {
1024
+ const contextStorageKey = await namespaceForContextData(crypto, environmentNamespace, context);
1025
+ const json = await storage.get(`${contextStorageKey}${FRESHNESS_SUFFIX}`);
1026
+ if (json === null || json === undefined) {
1027
+ return undefined;
1028
+ }
1029
+ try {
1030
+ const record = JSON.parse(json);
1031
+ const currentHash = await hashContext(crypto, context);
1032
+ if (currentHash === undefined || record.contextHash !== currentHash) {
1033
+ return undefined;
1034
+ }
1035
+ return typeof record.timestamp === 'number' && !Number.isNaN(record.timestamp)
1036
+ ? record.timestamp
1037
+ : undefined;
1038
+ }
1039
+ catch (e) {
1040
+ logger?.warn(`Could not read freshness data from persistent storage: ${e.message}`);
1041
+ return undefined;
1042
+ }
1043
+ }
821
1044
 
822
1045
  function isValidFlag(value) {
823
1046
  return value !== null && typeof value === 'object' && typeof value.version === 'number';
@@ -971,6 +1194,19 @@ class FlagPersistence {
971
1194
  }
972
1195
  return false;
973
1196
  }
1197
+ /**
1198
+ * Applies a changeset to the flag store.
1199
+ * - `'full'`: replaces all flags via {@link FlagUpdater.init}.
1200
+ * - `'partial'`: upserts individual flags via {@link FlagUpdater.upsert}.
1201
+ * - `'none'`: no flag changes, only persists cache to update freshness.
1202
+ *
1203
+ * Always persists to cache afterwards, which updates the freshness timestamp
1204
+ * even when no flags change (e.g., transfer-none).
1205
+ */
1206
+ async applyChanges(context, updates, type) {
1207
+ this._flagUpdater.applyChanges(context, updates, type);
1208
+ await this._storeCache(context);
1209
+ }
974
1210
  /**
975
1211
  * Loads the flags from persistence for the provided context and gives those to the
976
1212
  * {@link FlagUpdater} this {@link FlagPersistence} was constructed with.
@@ -1094,6 +1330,16 @@ function createDefaultFlagStore() {
1094
1330
  getAll() {
1095
1331
  return flags;
1096
1332
  },
1333
+ applyChanges(updates, type) {
1334
+ if (type === 'full') {
1335
+ this.init(updates);
1336
+ }
1337
+ else if (type === 'partial') {
1338
+ Object.entries(updates).forEach(([key, descriptor]) => {
1339
+ flags[key] = descriptor;
1340
+ });
1341
+ }
1342
+ },
1097
1343
  };
1098
1344
  }
1099
1345
 
@@ -1151,6 +1397,24 @@ function createFlagUpdater(_flagStore, _logger) {
1151
1397
  }
1152
1398
  this.init(context, newFlags);
1153
1399
  },
1400
+ applyChanges(context, updates, type) {
1401
+ activeContext = context;
1402
+ const oldFlags = flagStore.getAll();
1403
+ flagStore.applyChanges(updates, type);
1404
+ if (type === 'full') {
1405
+ const changed = calculateChangedKeys(oldFlags, updates);
1406
+ if (changed.length > 0) {
1407
+ this.handleFlagChanges(changed, 'init');
1408
+ }
1409
+ }
1410
+ else if (type === 'partial') {
1411
+ const keys = Object.keys(updates);
1412
+ if (keys.length > 0) {
1413
+ this.handleFlagChanges(keys, 'patch');
1414
+ }
1415
+ }
1416
+ // 'none' — no flag changes, caller handles freshness.
1417
+ },
1154
1418
  upsert(context, key, item) {
1155
1419
  if (activeContext?.canonicalKey !== context.canonicalKey) {
1156
1420
  logger.warn('Received an update for an inactive context.');
@@ -1230,6 +1494,9 @@ class DefaultFlagManager {
1230
1494
  async loadCached(context) {
1231
1495
  return (await this._flagPersistencePromise).loadCached(context);
1232
1496
  }
1497
+ async applyChanges(context, updates, type) {
1498
+ return (await this._flagPersistencePromise).applyChanges(context, updates, type);
1499
+ }
1233
1500
  on(callback) {
1234
1501
  this._flagUpdater.on(callback);
1235
1502
  }
@@ -2826,5 +3093,1991 @@ const DESKTOP_TRANSITION_TABLE = [
2826
3093
  { conditions: {}, mode: { configured: 'foreground' } },
2827
3094
  ];
2828
3095
 
2829
- export { BROWSER_DATA_SYSTEM_DEFAULTS, BROWSER_TRANSITION_TABLE, BaseDataManager, DESKTOP_DATA_SYSTEM_DEFAULTS, DESKTOP_TRANSITION_TABLE, DataSourceState, LDClientImpl, MOBILE_DATA_SYSTEM_DEFAULTS, MOBILE_TRANSITION_TABLE, browserFdv1Endpoints, dataSystemValidators, fdv2Endpoints, makeRequestor, mobileFdv1Endpoints, readFlagsFromBootstrap, resolveConnectionMode, safeRegisterDebugOverridePlugins, validateOptions };
3096
+ /**
3097
+ * Creates a change set result containing processed flag data.
3098
+ */
3099
+ function changeSet(payload, fdv1Fallback, environmentId, freshness) {
3100
+ return { type: 'changeSet', payload, fdv1Fallback, environmentId, freshness };
3101
+ }
3102
+ /**
3103
+ * Creates an interrupted status result. Indicates a transient error; the
3104
+ * synchronizer will attempt to recover automatically.
3105
+ *
3106
+ * When used with an initializer, this is still a terminal state.
3107
+ */
3108
+ function interrupted(errorInfo, fdv1Fallback) {
3109
+ return { type: 'status', state: 'interrupted', errorInfo, fdv1Fallback };
3110
+ }
3111
+ /**
3112
+ * Creates a shutdown status result. Indicates the data source was closed
3113
+ * gracefully and will not produce any further results.
3114
+ */
3115
+ function shutdown() {
3116
+ return { type: 'status', state: 'shutdown', fdv1Fallback: false };
3117
+ }
3118
+ /**
3119
+ * Creates a terminal error status result. Indicates an unrecoverable error;
3120
+ * the data source will not produce any further results.
3121
+ */
3122
+ function terminalError(errorInfo, fdv1Fallback) {
3123
+ return { type: 'status', state: 'terminal_error', errorInfo, fdv1Fallback };
3124
+ }
3125
+ /**
3126
+ * Creates a goodbye status result. Indicates the server has instructed the
3127
+ * client to disconnect.
3128
+ */
3129
+ function goodbye(reason, fdv1Fallback) {
3130
+ return { type: 'status', state: 'goodbye', reason, fdv1Fallback };
3131
+ }
3132
+ /**
3133
+ * Helper to create a {@link DataSourceStatusErrorInfo} from an HTTP status code.
3134
+ */
3135
+ function errorInfoFromHttpError(statusCode) {
3136
+ return {
3137
+ kind: DataSourceErrorKind.ErrorResponse,
3138
+ message: `Unexpected status code: ${statusCode}`,
3139
+ statusCode,
3140
+ time: Date.now(),
3141
+ };
3142
+ }
3143
+ /**
3144
+ * Helper to create a {@link DataSourceStatusErrorInfo} from a network error.
3145
+ */
3146
+ function errorInfoFromNetworkError(message) {
3147
+ return {
3148
+ kind: DataSourceErrorKind.NetworkError,
3149
+ message,
3150
+ time: Date.now(),
3151
+ };
3152
+ }
3153
+ /**
3154
+ * Helper to create a {@link DataSourceStatusErrorInfo} from invalid data.
3155
+ */
3156
+ function errorInfoFromInvalidData(message) {
3157
+ return {
3158
+ kind: DataSourceErrorKind.InvalidData,
3159
+ message,
3160
+ time: Date.now(),
3161
+ };
3162
+ }
3163
+ /**
3164
+ * Helper to create a {@link DataSourceStatusErrorInfo} for unknown errors.
3165
+ */
3166
+ function errorInfoFromUnknown(message) {
3167
+ return {
3168
+ kind: DataSourceErrorKind.Unknown,
3169
+ message,
3170
+ time: Date.now(),
3171
+ };
3172
+ }
3173
+
3174
+ /**
3175
+ * Strips the `version` field from a stored {@link Flag} to produce the
3176
+ * `FlagEvaluationResult` shape expected in an FDv2 `Update.object`.
3177
+ *
3178
+ * The version is carried on the `Update` envelope, not on the object itself.
3179
+ */
3180
+ function flagToEvaluationResult(flag) {
3181
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
3182
+ const { version, ...evalResult } = flag;
3183
+ return evalResult;
3184
+ }
3185
+ /**
3186
+ * Reads cached flag data and freshness from platform storage and returns
3187
+ * them as an {@link FDv2SourceResult}.
3188
+ */
3189
+ async function loadFromCache(config) {
3190
+ const { storage, crypto, environmentNamespace, context, logger } = config;
3191
+ if (!storage) {
3192
+ logger?.debug('No storage available for cache initializer');
3193
+ return interrupted(errorInfoFromUnknown('No storage available'), false);
3194
+ }
3195
+ const cached = await loadCachedFlags(storage, crypto, environmentNamespace, context, logger);
3196
+ if (!cached) {
3197
+ logger?.debug('Cache miss for context');
3198
+ return interrupted(errorInfoFromUnknown('Cache miss'), false);
3199
+ }
3200
+ const updates = Object.entries(cached.flags).map(([key, flag]) => ({
3201
+ kind: 'flag-eval',
3202
+ key,
3203
+ version: flag.version,
3204
+ object: flagToEvaluationResult(flag),
3205
+ }));
3206
+ const payload = {
3207
+ version: 0,
3208
+ // No `state` field. The orchestrator sees a changeSet without a selector,
3209
+ // records dataReceived=true, and continues to the next initializer.
3210
+ type: 'full',
3211
+ updates,
3212
+ };
3213
+ const freshness = await readFreshness(storage, crypto, environmentNamespace, context, logger);
3214
+ logger?.debug('Loaded cached flag evaluations via cache initializer');
3215
+ return changeSet(payload, false, undefined, freshness);
3216
+ }
3217
+ /**
3218
+ * Creates an {@link InitializerFactory} that produces cache initializers.
3219
+ *
3220
+ * The cache initializer reads flag data and freshness from persistent storage
3221
+ * for the given context and returns them as a changeSet without a selector.
3222
+ * This allows the orchestrator to provide cached data immediately while
3223
+ * continuing to the next initializer for network-verified data.
3224
+ *
3225
+ * Per spec Requirement 4.1.2, the payload has `persist=false` semantics
3226
+ * (no selector) so the consuming layer should not re-persist it.
3227
+ *
3228
+ * @internal
3229
+ */
3230
+ function createCacheInitializerFactory(config) {
3231
+ // The selectorGetter is ignored — cache data has no selector.
3232
+ return (_selectorGetter) => {
3233
+ let shutdownResolve;
3234
+ const shutdownPromise = new Promise((resolve) => {
3235
+ shutdownResolve = resolve;
3236
+ });
3237
+ return {
3238
+ async run() {
3239
+ return Promise.race([shutdownPromise, loadFromCache(config)]);
3240
+ },
3241
+ close() {
3242
+ shutdownResolve?.(shutdown());
3243
+ shutdownResolve = undefined;
3244
+ },
3245
+ };
3246
+ };
3247
+ }
3248
+
3249
+ /**
3250
+ * Creates an {@link FDv2Requestor} for client-side FDv2 polling.
3251
+ *
3252
+ * @param plainContextString The JSON-serialized evaluation context.
3253
+ * @param serviceEndpoints Service endpoint configuration.
3254
+ * @param paths FDv2 polling endpoint paths.
3255
+ * @param requests Platform HTTP abstraction.
3256
+ * @param encoding Platform encoding abstraction.
3257
+ * @param baseHeaders Default HTTP headers (e.g. authorization).
3258
+ * @param baseQueryParams Additional query parameters to include on every request.
3259
+ * @param usePost If true, use POST with context in body instead of GET with
3260
+ * context in URL path.
3261
+ */
3262
+ function makeFDv2Requestor(plainContextString, serviceEndpoints, paths, requests, encoding, baseHeaders, baseQueryParams, usePost) {
3263
+ const headers = { ...baseHeaders };
3264
+ let body;
3265
+ let method = 'GET';
3266
+ let path;
3267
+ if (usePost) {
3268
+ method = 'POST';
3269
+ headers['content-type'] = 'application/json';
3270
+ body = plainContextString;
3271
+ path = paths.pathPost(encoding, plainContextString);
3272
+ }
3273
+ else {
3274
+ path = paths.pathGet(encoding, plainContextString);
3275
+ }
3276
+ return {
3277
+ async poll(basis) {
3278
+ const parameters = [...(baseQueryParams ?? [])];
3279
+ // Intentionally falsy check: an empty string basis must not be sent as
3280
+ // a query parameter, since it does not represent a valid selector.
3281
+ if (basis) {
3282
+ parameters.push({ key: 'basis', value: basis });
3283
+ }
3284
+ const uri = getPollingUri(serviceEndpoints, path, parameters);
3285
+ const res = await requests.fetch(uri, {
3286
+ method,
3287
+ headers,
3288
+ body,
3289
+ });
3290
+ const responseBody = res.status === 304 ? null : await res.text();
3291
+ return {
3292
+ status: res.status,
3293
+ headers: res.headers,
3294
+ body: responseBody,
3295
+ };
3296
+ },
3297
+ };
3298
+ }
3299
+
3300
+ /**
3301
+ * ObjProcessor for the `flag-eval` object kind. Used by the protocol handler to
3302
+ * process objects received in `put-object` events.
3303
+ *
3304
+ * Client-side evaluation results are already in their final form (pre-evaluated
3305
+ * by the server), so no transformation is needed — this is a passthrough.
3306
+ */
3307
+ function processFlagEval(object) {
3308
+ return object;
3309
+ }
3310
+ /**
3311
+ * Converts an FDv2 {@link internal.Update} with `kind: 'flag-eval'` into an
3312
+ * {@link ItemDescriptor} suitable for {@link FlagManager}.
3313
+ *
3314
+ * For put updates the envelope `version` is used as the {@link ItemDescriptor.version}
3315
+ * and as {@link Flag.version}. The rest of the fields are spread from the
3316
+ * {@link FlagEvaluationResult} object.
3317
+ *
3318
+ * For delete updates a tombstone descriptor is created with `deleted: true`.
3319
+ */
3320
+ function flagEvalUpdateToItemDescriptor(update) {
3321
+ if (update.deleted) {
3322
+ return {
3323
+ version: update.version,
3324
+ flag: {
3325
+ version: update.version,
3326
+ deleted: true,
3327
+ value: undefined,
3328
+ trackEvents: false,
3329
+ },
3330
+ };
3331
+ }
3332
+ const evalResult = update.object;
3333
+ return {
3334
+ version: update.version,
3335
+ flag: {
3336
+ ...evalResult,
3337
+ version: update.version,
3338
+ },
3339
+ };
3340
+ }
3341
+ /**
3342
+ * Converts an array of FDv2 payload updates into a map of flag key to
3343
+ * {@link ItemDescriptor}. Only `flag-eval` kind updates are processed;
3344
+ * unrecognized kinds are silently ignored.
3345
+ */
3346
+ function flagEvalPayloadToItemDescriptors(updates) {
3347
+ const descriptors = {};
3348
+ updates.forEach((update) => {
3349
+ if (update.kind === 'flag-eval') {
3350
+ descriptors[update.key] = flagEvalUpdateToItemDescriptor(update);
3351
+ }
3352
+ });
3353
+ return descriptors;
3354
+ }
3355
+
3356
+ function getFallback(headers) {
3357
+ const value = headers.get('x-ld-fd-fallback');
3358
+ return value !== null && value.toLowerCase() === 'true';
3359
+ }
3360
+ function getEnvironmentId(headers) {
3361
+ return headers.get('x-ld-envid') ?? undefined;
3362
+ }
3363
+ /**
3364
+ * Process FDv2 events using the protocol handler directly.
3365
+ *
3366
+ * We use `createProtocolHandler` rather than `PayloadProcessor` because
3367
+ * the PayloadProcessor does not surface goodbye/serverError actions —
3368
+ * it only forwards payloads and actionable errors. For polling results,
3369
+ * we need full control over all protocol action types.
3370
+ */
3371
+ function processEvents(events, fdv1Fallback, environmentId, logger) {
3372
+ const handler = internal.createProtocolHandler({
3373
+ 'flag-eval': processFlagEval,
3374
+ }, logger);
3375
+ let earlyResult;
3376
+ events.forEach((event) => {
3377
+ if (earlyResult) {
3378
+ return;
3379
+ }
3380
+ const action = handler.processEvent(event);
3381
+ switch (action.type) {
3382
+ case 'payload':
3383
+ earlyResult = changeSet(action.payload, fdv1Fallback, environmentId);
3384
+ break;
3385
+ case 'goodbye':
3386
+ earlyResult = goodbye(action.reason, fdv1Fallback);
3387
+ break;
3388
+ case 'serverError': {
3389
+ const errorInfo = errorInfoFromUnknown(action.reason);
3390
+ logger?.error(`Server error during polling: ${action.reason}`);
3391
+ earlyResult = interrupted(errorInfo, fdv1Fallback);
3392
+ break;
3393
+ }
3394
+ case 'error': {
3395
+ // Actionable protocol errors (MISSING_PAYLOAD, PROTOCOL_ERROR)
3396
+ if (action.kind === 'MISSING_PAYLOAD' || action.kind === 'PROTOCOL_ERROR') {
3397
+ const errorInfo = errorInfoFromInvalidData(action.message);
3398
+ logger?.warn(`Protocol error during polling: ${action.message}`);
3399
+ earlyResult = interrupted(errorInfo, fdv1Fallback);
3400
+ }
3401
+ else {
3402
+ // Non-actionable errors (UNKNOWN_EVENT) are logged but don't stop processing
3403
+ logger?.warn(action.message);
3404
+ }
3405
+ break;
3406
+ }
3407
+ }
3408
+ });
3409
+ if (earlyResult) {
3410
+ return earlyResult;
3411
+ }
3412
+ // Events didn't produce a result
3413
+ const errorInfo = errorInfoFromUnknown('Unexpected end of polling response');
3414
+ logger?.error('Unexpected end of polling response');
3415
+ return interrupted(errorInfo, fdv1Fallback);
3416
+ }
3417
+ /**
3418
+ * Performs a single FDv2 poll request, processes the protocol response, and
3419
+ * returns an {@link FDv2SourceResult}.
3420
+ *
3421
+ * Recoverable errors produce interrupted results; unrecoverable HTTP errors
3422
+ * produce terminal errors.
3423
+ *
3424
+ * @internal
3425
+ */
3426
+ async function poll(requestor, basis, logger) {
3427
+ let fdv1Fallback = false;
3428
+ let environmentId;
3429
+ try {
3430
+ const response = await requestor.poll(basis);
3431
+ fdv1Fallback = getFallback(response.headers);
3432
+ environmentId = getEnvironmentId(response.headers);
3433
+ // 304 Not Modified: treat as server-intent with intentCode 'none'
3434
+ // (Spec Requirement 10.1.2)
3435
+ if (response.status === 304) {
3436
+ const nonePayload = {
3437
+ version: 0,
3438
+ type: 'none',
3439
+ updates: [],
3440
+ };
3441
+ return changeSet(nonePayload, fdv1Fallback, environmentId);
3442
+ }
3443
+ // Non-success HTTP status
3444
+ if (response.status < 200 || response.status >= 300) {
3445
+ const errorInfo = errorInfoFromHttpError(response.status);
3446
+ logger?.error(`Polling request failed with HTTP error: ${response.status}`);
3447
+ const recoverable = response.status <= 0 || isHttpRecoverable(response.status);
3448
+ return recoverable
3449
+ ? interrupted(errorInfo, fdv1Fallback)
3450
+ : terminalError(errorInfo, fdv1Fallback);
3451
+ }
3452
+ // Successful response — process FDv2 events
3453
+ if (!response.body) {
3454
+ const errorInfo = errorInfoFromInvalidData('Empty response body');
3455
+ logger?.error('Polling request received empty response body');
3456
+ return interrupted(errorInfo, fdv1Fallback);
3457
+ }
3458
+ let parsed;
3459
+ try {
3460
+ parsed = JSON.parse(response.body);
3461
+ }
3462
+ catch {
3463
+ const errorInfo = errorInfoFromInvalidData('Malformed JSON data in polling response');
3464
+ logger?.error('Polling request received malformed data');
3465
+ return interrupted(errorInfo, fdv1Fallback);
3466
+ }
3467
+ if (!Array.isArray(parsed.events)) {
3468
+ const errorInfo = errorInfoFromInvalidData('Invalid polling response: missing or invalid events array');
3469
+ logger?.error('Polling response does not contain a valid events array');
3470
+ return interrupted(errorInfo, fdv1Fallback);
3471
+ }
3472
+ return processEvents(parsed.events, fdv1Fallback, environmentId, logger);
3473
+ }
3474
+ catch (err) {
3475
+ // Network or other I/O error from the fetch itself
3476
+ const message = err?.message ?? String(err);
3477
+ logger?.error(`Polling request failed with network error: ${message}`);
3478
+ const errorInfo = errorInfoFromNetworkError(message);
3479
+ return interrupted(errorInfo, fdv1Fallback);
3480
+ }
3481
+ }
3482
+
3483
+ const SHUTDOWN = Symbol('shutdown');
3484
+ /**
3485
+ * Creates a polling initializer that performs an FDv2 poll request with
3486
+ * retry logic. Retries up to 3 times on recoverable errors with a 1-second
3487
+ * delay between attempts.
3488
+ *
3489
+ * Unrecoverable errors (401, 403, etc.) are returned immediately as terminal
3490
+ * errors. After exhausting retries on recoverable errors, the result is
3491
+ * converted to a terminal error.
3492
+ *
3493
+ * If `close()` is called during a poll or retry delay, the result will be
3494
+ * a shutdown status.
3495
+ *
3496
+ * @internal
3497
+ */
3498
+ function createPollingInitializer(requestor, logger, selectorGetter) {
3499
+ let shutdownResolve;
3500
+ const shutdownPromise = new Promise((resolve) => {
3501
+ shutdownResolve = resolve;
3502
+ });
3503
+ return {
3504
+ async run() {
3505
+ const maxRetries = 3;
3506
+ const retryDelayMs = 1000;
3507
+ const selector = selectorGetter();
3508
+ let lastResult;
3509
+ for (let attempt = 0; attempt <= maxRetries; attempt += 1) {
3510
+ // eslint-disable-next-line no-await-in-loop
3511
+ const result = await Promise.race([shutdownPromise, poll(requestor, selector, logger)]);
3512
+ if (result === SHUTDOWN) {
3513
+ return shutdown();
3514
+ }
3515
+ if (result.type === 'changeSet') {
3516
+ return result;
3517
+ }
3518
+ // Non-retryable status (terminal_error, goodbye) -> return immediately
3519
+ if (result.state !== 'interrupted') {
3520
+ return result;
3521
+ }
3522
+ // Recoverable error — save and potentially retry
3523
+ lastResult = result;
3524
+ if (attempt < maxRetries) {
3525
+ logger?.warn(`Recoverable polling error (attempt ${attempt + 1}/${maxRetries + 1}), retrying in ${retryDelayMs}ms...`);
3526
+ // eslint-disable-next-line no-await-in-loop
3527
+ const sleepResult = await Promise.race([shutdownPromise, sleep(retryDelayMs)]);
3528
+ if (sleepResult === SHUTDOWN) {
3529
+ return shutdown();
3530
+ }
3531
+ }
3532
+ }
3533
+ // Convert final interrupted -> terminal_error
3534
+ const status = lastResult;
3535
+ return terminalError(status.errorInfo, status.fdv1Fallback);
3536
+ },
3537
+ close() {
3538
+ shutdownResolve?.(SHUTDOWN);
3539
+ shutdownResolve = undefined;
3540
+ },
3541
+ };
3542
+ }
3543
+
3544
+ /**
3545
+ * Creates a new {@link AsyncQueue}.
3546
+ *
3547
+ * @internal
3548
+ */
3549
+ function createAsyncQueue() {
3550
+ const items = [];
3551
+ const waiters = [];
3552
+ return {
3553
+ put(item) {
3554
+ const waiter = waiters.shift();
3555
+ if (waiter) {
3556
+ waiter(item);
3557
+ }
3558
+ else {
3559
+ items.push(item);
3560
+ }
3561
+ },
3562
+ take() {
3563
+ if (items.length > 0) {
3564
+ return Promise.resolve(items.shift());
3565
+ }
3566
+ return new Promise((resolve) => {
3567
+ waiters.push(resolve);
3568
+ });
3569
+ },
3570
+ };
3571
+ }
3572
+
3573
+ /**
3574
+ * Creates a continuous polling synchronizer that periodically polls for FDv2
3575
+ * data and yields results via successive calls to `next()`.
3576
+ *
3577
+ * The first poll fires immediately. Subsequent polls are scheduled using
3578
+ * `setTimeout` after each poll completes, ensuring sequential execution and
3579
+ * preventing overlapping requests on slow networks.
3580
+ *
3581
+ * Results are buffered in an async queue. On terminal errors, polling stops
3582
+ * and the shutdown future is resolved. On `close()`, polling stops and the
3583
+ * next `next()` call returns a shutdown result.
3584
+ *
3585
+ * @internal
3586
+ */
3587
+ function createPollingSynchronizer(requestor, logger, selectorGetter, pollIntervalMs) {
3588
+ const resultQueue = createAsyncQueue();
3589
+ let shutdownResolve;
3590
+ const shutdownPromise = new Promise((resolve) => {
3591
+ shutdownResolve = resolve;
3592
+ });
3593
+ let timeoutHandle;
3594
+ let stopped = false;
3595
+ async function doPoll() {
3596
+ if (stopped) {
3597
+ return;
3598
+ }
3599
+ const startTime = Date.now();
3600
+ try {
3601
+ const result = await poll(requestor, selectorGetter(), logger);
3602
+ if (stopped) {
3603
+ return;
3604
+ }
3605
+ let shouldShutdown = false;
3606
+ if (result.type === 'status') {
3607
+ switch (result.state) {
3608
+ case 'terminal_error':
3609
+ stopped = true;
3610
+ shouldShutdown = true;
3611
+ break;
3612
+ case 'interrupted':
3613
+ case 'goodbye':
3614
+ // Continue polling on transient errors and goodbyes
3615
+ break;
3616
+ case 'shutdown':
3617
+ // The base poll function doesn't emit shutdown; we handle it
3618
+ // at this level via close().
3619
+ break;
3620
+ default:
3621
+ break;
3622
+ }
3623
+ }
3624
+ if (shouldShutdown) {
3625
+ shutdownResolve?.(result);
3626
+ shutdownResolve = undefined;
3627
+ }
3628
+ else {
3629
+ resultQueue.put(result);
3630
+ }
3631
+ }
3632
+ catch (err) {
3633
+ logger?.debug(`Polling error: ${err}`);
3634
+ }
3635
+ // Schedule next poll after completion, accounting for elapsed time.
3636
+ // This ensures sequential execution — no overlapping requests.
3637
+ if (!stopped) {
3638
+ const elapsed = Date.now() - startTime;
3639
+ const sleepFor = Math.min(Math.max(pollIntervalMs - elapsed, 0), pollIntervalMs);
3640
+ timeoutHandle = setTimeout(() => {
3641
+ doPoll();
3642
+ }, sleepFor);
3643
+ }
3644
+ }
3645
+ // Start polling immediately
3646
+ doPoll();
3647
+ return {
3648
+ async next() {
3649
+ return Promise.race([shutdownPromise, resultQueue.take()]);
3650
+ },
3651
+ close() {
3652
+ stopped = true;
3653
+ if (timeoutHandle !== undefined) {
3654
+ clearTimeout(timeoutHandle);
3655
+ timeoutHandle = undefined;
3656
+ }
3657
+ shutdownResolve?.(shutdown());
3658
+ shutdownResolve = undefined;
3659
+ },
3660
+ };
3661
+ }
3662
+
3663
+ /**
3664
+ * Creates a {@link SynchronizerSlot}.
3665
+ *
3666
+ * @param factory The synchronizer factory function.
3667
+ * @param options Optional configuration.
3668
+ * @param options.isFDv1Fallback Whether this slot is the FDv1 fallback adapter.
3669
+ * FDv1 slots start `'blocked'` by default.
3670
+ * @param options.initialState Override the initial state (defaults to
3671
+ * `'blocked'` for FDv1 slots, `'available'` otherwise).
3672
+ */
3673
+ function createSynchronizerSlot(factory, options) {
3674
+ const isFDv1Fallback = options?.isFDv1Fallback ?? false;
3675
+ const state = options?.initialState ?? (isFDv1Fallback ? 'blocked' : 'available');
3676
+ return { factory, isFDv1Fallback, state };
3677
+ }
3678
+ /**
3679
+ * Creates a {@link SourceManager} that coordinates initializer and
3680
+ * synchronizer lifecycle.
3681
+ *
3682
+ * @param initializerFactories Ordered list of initializer factories.
3683
+ * @param synchronizerSlots Ordered list of synchronizer slots with state.
3684
+ * @param selectorGetter Closure that returns the current selector string.
3685
+ */
3686
+ function createSourceManager(initializerFactories, synchronizerSlots, selectorGetter) {
3687
+ let activeSource;
3688
+ let initializerIndex = -1;
3689
+ let synchronizerIndex = -1;
3690
+ let isShutdown = false;
3691
+ function closeActiveSource() {
3692
+ if (activeSource) {
3693
+ activeSource.close();
3694
+ activeSource = undefined;
3695
+ }
3696
+ }
3697
+ function findFirstAvailableIndex() {
3698
+ return synchronizerSlots.findIndex((slot) => slot.state === 'available');
3699
+ }
3700
+ return {
3701
+ get isShutdown() {
3702
+ return isShutdown;
3703
+ },
3704
+ getNextInitializerAndSetActive() {
3705
+ if (isShutdown) {
3706
+ return undefined;
3707
+ }
3708
+ initializerIndex += 1;
3709
+ if (initializerIndex >= initializerFactories.length) {
3710
+ return undefined;
3711
+ }
3712
+ closeActiveSource();
3713
+ const initializer = initializerFactories[initializerIndex](selectorGetter);
3714
+ activeSource = initializer;
3715
+ return initializer;
3716
+ },
3717
+ getNextAvailableSynchronizerAndSetActive() {
3718
+ if (isShutdown || synchronizerSlots.length === 0) {
3719
+ return undefined;
3720
+ }
3721
+ // Scan all slots starting from the position after the current one,
3722
+ // wrapping around to the beginning if needed. This matches the Java
3723
+ // SourceManager behavior where synchronizers cycle rather than exhausting.
3724
+ let visited = 0;
3725
+ while (visited < synchronizerSlots.length) {
3726
+ synchronizerIndex += 1;
3727
+ if (synchronizerIndex >= synchronizerSlots.length) {
3728
+ synchronizerIndex = 0;
3729
+ }
3730
+ const candidate = synchronizerSlots[synchronizerIndex];
3731
+ if (candidate.state === 'available') {
3732
+ closeActiveSource();
3733
+ const synchronizer = candidate.factory(selectorGetter);
3734
+ activeSource = synchronizer;
3735
+ return synchronizer;
3736
+ }
3737
+ visited += 1;
3738
+ }
3739
+ return undefined;
3740
+ },
3741
+ blockCurrentSynchronizer() {
3742
+ if (synchronizerIndex >= 0 && synchronizerIndex < synchronizerSlots.length) {
3743
+ // eslint-disable-next-line no-param-reassign
3744
+ synchronizerSlots[synchronizerIndex].state = 'blocked';
3745
+ }
3746
+ },
3747
+ resetSourceIndex() {
3748
+ synchronizerIndex = -1;
3749
+ },
3750
+ fdv1Fallback() {
3751
+ synchronizerSlots.forEach((slot) => {
3752
+ // eslint-disable-next-line no-param-reassign
3753
+ slot.state = slot.isFDv1Fallback ? 'available' : 'blocked';
3754
+ });
3755
+ synchronizerIndex = -1;
3756
+ },
3757
+ isPrimeSynchronizer() {
3758
+ return synchronizerIndex === findFirstAvailableIndex();
3759
+ },
3760
+ getAvailableSynchronizerCount() {
3761
+ return synchronizerSlots.filter((slot) => slot.state === 'available').length;
3762
+ },
3763
+ hasFDv1Fallback() {
3764
+ return synchronizerSlots.some((slot) => slot.isFDv1Fallback);
3765
+ },
3766
+ close() {
3767
+ isShutdown = true;
3768
+ closeActiveSource();
3769
+ },
3770
+ };
3771
+ }
3772
+
3773
+ /**
3774
+ * FDv2 event names to listen for on the EventSource. This must stay in sync
3775
+ * with the `EventType` union defined in `@launchdarkly/js-sdk-common`'s
3776
+ * `internal/fdv2/proto.ts`.
3777
+ */
3778
+ const FDV2_EVENT_NAMES = [
3779
+ 'server-intent',
3780
+ 'put-object',
3781
+ 'delete-object',
3782
+ 'payload-transferred',
3783
+ 'goodbye',
3784
+ 'error',
3785
+ 'heart-beat',
3786
+ ];
3787
+ /**
3788
+ * Creates the core streaming base for FDv2 client-side data sources.
3789
+ *
3790
+ * Manages an EventSource connection, processes FDv2 protocol events using
3791
+ * a protocol handler from the common package, detects FDv1 fallback signals,
3792
+ * handles legacy ping events, and queues results for consumption by
3793
+ * {@link createStreamingInitializer} or {@link createStreamingSynchronizer}.
3794
+ *
3795
+ * @internal
3796
+ */
3797
+ function createStreamingBase(config) {
3798
+ const resultQueue = createAsyncQueue();
3799
+ const protocolHandler = internal.createProtocolHandler({ 'flag-eval': processFlagEval }, config.logger);
3800
+ const headers = { ...config.headers };
3801
+ function buildStreamUri() {
3802
+ const params = [...config.parameters];
3803
+ const basis = config.selectorGetter?.();
3804
+ if (basis) {
3805
+ params.push({ key: 'basis', value: encodeURIComponent(basis) });
3806
+ }
3807
+ return getStreamingUri(config.serviceEndpoints, config.streamUriPath, params);
3808
+ }
3809
+ let eventSource;
3810
+ let connectionAttemptStartTime;
3811
+ let fdv1Fallback = false;
3812
+ let started = false;
3813
+ let stopped = false;
3814
+ function logConnectionAttempt() {
3815
+ connectionAttemptStartTime = Date.now();
3816
+ }
3817
+ function logConnectionResult(success) {
3818
+ if (connectionAttemptStartTime && config.diagnosticsManager) {
3819
+ config.diagnosticsManager.recordStreamInit(connectionAttemptStartTime, !success, Date.now() - connectionAttemptStartTime);
3820
+ }
3821
+ connectionAttemptStartTime = undefined;
3822
+ }
3823
+ function handleAction(action) {
3824
+ switch (action.type) {
3825
+ case 'payload':
3826
+ logConnectionResult(true);
3827
+ resultQueue.put(changeSet(action.payload, fdv1Fallback));
3828
+ break;
3829
+ case 'goodbye':
3830
+ resultQueue.put(goodbye(action.reason, fdv1Fallback));
3831
+ break;
3832
+ case 'serverError':
3833
+ resultQueue.put(interrupted(errorInfoFromUnknown(action.reason), fdv1Fallback));
3834
+ break;
3835
+ case 'error':
3836
+ // Only actionable errors are queued; informational ones (UNKNOWN_EVENT)
3837
+ // are logged by the protocol handler.
3838
+ if (action.kind === 'MISSING_PAYLOAD' || action.kind === 'PROTOCOL_ERROR') {
3839
+ resultQueue.put(interrupted(errorInfoFromInvalidData(action.message), fdv1Fallback));
3840
+ }
3841
+ break;
3842
+ }
3843
+ }
3844
+ function handleError(err) {
3845
+ // Check for FDv1 fallback header.
3846
+ if (err.headers?.['x-ld-fd-fallback'] === 'true') {
3847
+ fdv1Fallback = true;
3848
+ logConnectionResult(false);
3849
+ resultQueue.put(terminalError(errorInfoFromHttpError(err.status ?? 0), true));
3850
+ return false;
3851
+ }
3852
+ if (!shouldRetry(err)) {
3853
+ config.logger?.error(httpErrorMessage(err, 'streaming request'));
3854
+ logConnectionResult(false);
3855
+ resultQueue.put(terminalError(errorInfoFromHttpError(err.status ?? 0), fdv1Fallback));
3856
+ return false;
3857
+ }
3858
+ config.logger?.warn(httpErrorMessage(err, 'streaming request', 'will retry'));
3859
+ logConnectionResult(false);
3860
+ logConnectionAttempt();
3861
+ resultQueue.put(interrupted(errorInfoFromHttpError(err.status ?? 0), fdv1Fallback));
3862
+ return true;
3863
+ }
3864
+ function attachFDv2Listeners(es) {
3865
+ FDV2_EVENT_NAMES.forEach((eventName) => {
3866
+ es.addEventListener(eventName, (event) => {
3867
+ if (stopped) {
3868
+ config.logger?.debug(`Received ${eventName} event after processor was closed. Skipping.`);
3869
+ return;
3870
+ }
3871
+ if (!event?.data) {
3872
+ // Some events (e.g. 'error') may legitimately arrive without a body.
3873
+ if (eventName !== 'error') {
3874
+ config.logger?.warn(`Event from EventStream missing data for "${eventName}".`);
3875
+ }
3876
+ return;
3877
+ }
3878
+ config.logger?.debug(`Received ${eventName} event`);
3879
+ let parsed;
3880
+ try {
3881
+ parsed = JSON.parse(event.data);
3882
+ }
3883
+ catch {
3884
+ config.logger?.error(`Stream received data that was unable to be parsed in "${eventName}" message`);
3885
+ config.logger?.debug(`Data follows: ${event.data}`);
3886
+ resultQueue.put(interrupted(errorInfoFromInvalidData('Malformed JSON in EventStream'), fdv1Fallback));
3887
+ return;
3888
+ }
3889
+ const action = protocolHandler.processEvent({ event: eventName, data: parsed });
3890
+ handleAction(action);
3891
+ });
3892
+ });
3893
+ }
3894
+ function attachPingListener(es) {
3895
+ es.addEventListener('ping', async () => {
3896
+ if (stopped) {
3897
+ config.logger?.debug('Ping received after processor was closed. Skipping.');
3898
+ return;
3899
+ }
3900
+ config.logger?.debug('Got PING, going to poll LaunchDarkly for feature flag updates');
3901
+ if (!config.pingHandler) {
3902
+ config.logger?.warn('Ping event received but no ping handler configured.');
3903
+ return;
3904
+ }
3905
+ try {
3906
+ const result = await config.pingHandler.handlePing();
3907
+ if (stopped) {
3908
+ config.logger?.debug('Ping completed after processor was closed. Skipping processing.');
3909
+ return;
3910
+ }
3911
+ resultQueue.put(result);
3912
+ }
3913
+ catch (err) {
3914
+ if (stopped) {
3915
+ return;
3916
+ }
3917
+ config.logger?.error(`Error handling ping: ${err?.message ?? err}`);
3918
+ resultQueue.put(interrupted(errorInfoFromNetworkError(err?.message ?? 'Error during ping poll'), fdv1Fallback));
3919
+ }
3920
+ });
3921
+ }
3922
+ return {
3923
+ start() {
3924
+ if (started || stopped) {
3925
+ return;
3926
+ }
3927
+ started = true;
3928
+ logConnectionAttempt();
3929
+ const es = config.requests.createEventSource(buildStreamUri(), {
3930
+ headers,
3931
+ errorFilter: (error) => handleError(error),
3932
+ initialRetryDelayMillis: config.initialRetryDelayMillis,
3933
+ readTimeoutMillis: 5 * 60 * 1000,
3934
+ retryResetIntervalMillis: 60 * 1000,
3935
+ urlBuilder: buildStreamUri,
3936
+ });
3937
+ eventSource = es;
3938
+ attachFDv2Listeners(es);
3939
+ attachPingListener(es);
3940
+ es.onclose = () => {
3941
+ config.logger?.info('Closed LaunchDarkly stream connection');
3942
+ };
3943
+ es.onerror = (err) => {
3944
+ if (stopped) {
3945
+ return;
3946
+ }
3947
+ if (err && typeof err.status === 'number') {
3948
+ // This condition will be handled by the error filter.
3949
+ return;
3950
+ }
3951
+ resultQueue.put(interrupted(errorInfoFromNetworkError(err?.message ?? 'IO Error'), fdv1Fallback));
3952
+ };
3953
+ es.onopen = () => {
3954
+ config.logger?.info('Opened LaunchDarkly stream connection');
3955
+ protocolHandler.reset();
3956
+ };
3957
+ es.onretrying = (e) => {
3958
+ config.logger?.info(`Will retry stream connection in ${e.delayMillis} milliseconds`);
3959
+ };
3960
+ },
3961
+ close() {
3962
+ if (stopped) {
3963
+ return;
3964
+ }
3965
+ stopped = true;
3966
+ eventSource?.close();
3967
+ eventSource = undefined;
3968
+ resultQueue.put(shutdown());
3969
+ },
3970
+ takeResult() {
3971
+ return resultQueue.take();
3972
+ },
3973
+ };
3974
+ }
3975
+
3976
+ /**
3977
+ * Creates a one-shot streaming initializer for FDv2.
3978
+ *
3979
+ * Connects to the FDv2 streaming endpoint, waits for the first result
3980
+ * (a change set or an error status), then disconnects. Used in browser
3981
+ * `one-shot` mode where a persistent connection is not desired.
3982
+ *
3983
+ * If `close()` is called before a result arrives, the returned promise
3984
+ * resolves with a shutdown result.
3985
+ *
3986
+ * @param base - The streaming base that manages the EventSource connection.
3987
+ * @internal
3988
+ */
3989
+ function createStreamingInitializer(base) {
3990
+ let closed = false;
3991
+ let shutdownResolve;
3992
+ const shutdownPromise = new Promise((resolve) => {
3993
+ shutdownResolve = resolve;
3994
+ });
3995
+ return {
3996
+ run() {
3997
+ if (closed) {
3998
+ return Promise.resolve(shutdown());
3999
+ }
4000
+ base.start();
4001
+ return Promise.race([
4002
+ base.takeResult().then((result) => {
4003
+ // Got our first result — close the connection.
4004
+ base.close();
4005
+ return result;
4006
+ }),
4007
+ shutdownPromise,
4008
+ ]);
4009
+ },
4010
+ close() {
4011
+ if (closed) {
4012
+ return;
4013
+ }
4014
+ closed = true;
4015
+ base.close();
4016
+ shutdownResolve?.(shutdown());
4017
+ shutdownResolve = undefined;
4018
+ },
4019
+ };
4020
+ }
4021
+
4022
+ /**
4023
+ * Creates a long-lived streaming synchronizer for FDv2.
4024
+ *
4025
+ * Maintains a persistent EventSource connection to the FDv2 streaming
4026
+ * endpoint and produces a stream of results via the pull-based `next()`
4027
+ * method. Used in streaming connection mode.
4028
+ *
4029
+ * The connection is started lazily on the first call to `next()`.
4030
+ * On `close()`, the next call to `next()` returns a shutdown result.
4031
+ *
4032
+ * @param base - The streaming base that manages the EventSource connection.
4033
+ * @internal
4034
+ */
4035
+ function createStreamingSynchronizer(base) {
4036
+ let started = false;
4037
+ let closed = false;
4038
+ let shutdownResolve;
4039
+ const shutdownPromise = new Promise((resolve) => {
4040
+ shutdownResolve = resolve;
4041
+ });
4042
+ return {
4043
+ next() {
4044
+ if (closed) {
4045
+ return Promise.resolve(shutdown());
4046
+ }
4047
+ if (!started) {
4048
+ started = true;
4049
+ base.start();
4050
+ }
4051
+ return Promise.race([base.takeResult(), shutdownPromise]);
4052
+ },
4053
+ close() {
4054
+ if (closed) {
4055
+ return;
4056
+ }
4057
+ closed = true;
4058
+ base.close();
4059
+ shutdownResolve?.(shutdown());
4060
+ shutdownResolve = undefined;
4061
+ },
4062
+ };
4063
+ }
4064
+
4065
+ function createPingHandler(requestor, selectorGetter, logger) {
4066
+ return {
4067
+ handlePing: () => poll(requestor, selectorGetter(), logger),
4068
+ };
4069
+ }
4070
+ /**
4071
+ * Create a {@link ServiceEndpoints} with per-entry endpoint overrides applied.
4072
+ * Returns the original endpoints if no overrides are specified.
4073
+ */
4074
+ function resolveEndpoints(ctx, endpoints) {
4075
+ if (!endpoints?.pollingBaseUri && !endpoints?.streamingBaseUri) {
4076
+ return ctx.serviceEndpoints;
4077
+ }
4078
+ return new ServiceEndpoints(endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, ctx.serviceEndpoints.events, ctx.serviceEndpoints.analyticsEventPath, ctx.serviceEndpoints.diagnosticEventPath, ctx.serviceEndpoints.includeAuthorizationHeader, ctx.serviceEndpoints.payloadFilterKey);
4079
+ }
4080
+ /**
4081
+ * Get the FDv2 requestor for a polling entry. If the entry has custom
4082
+ * endpoints, creates a new requestor targeting those endpoints. Otherwise
4083
+ * returns the shared requestor from the context.
4084
+ */
4085
+ function resolvePollingRequestor(ctx, endpoints) {
4086
+ if (!endpoints?.pollingBaseUri) {
4087
+ return ctx.requestor;
4088
+ }
4089
+ const overriddenEndpoints = resolveEndpoints(ctx, endpoints);
4090
+ return makeFDv2Requestor(ctx.plainContextString, overriddenEndpoints, ctx.polling.paths, ctx.requests, ctx.encoding, ctx.baseHeaders, ctx.queryParams);
4091
+ }
4092
+ /**
4093
+ * Build a streaming base instance using per-entry config with context defaults
4094
+ * as fallbacks. The `sg` selector getter is the canonical source of truth for
4095
+ * the current selector — both the stream and its ping handler use it.
4096
+ */
4097
+ function buildStreamingBase(entry, ctx, sg) {
4098
+ const entryEndpoints = resolveEndpoints(ctx, entry.endpoints);
4099
+ const requestor = resolvePollingRequestor(ctx, entry.endpoints);
4100
+ const streamUriPath = ctx.streaming.paths.pathGet(ctx.encoding, ctx.plainContextString);
4101
+ return createStreamingBase({
4102
+ requests: ctx.requests,
4103
+ serviceEndpoints: entryEndpoints,
4104
+ streamUriPath,
4105
+ parameters: ctx.queryParams,
4106
+ selectorGetter: sg,
4107
+ headers: ctx.baseHeaders,
4108
+ initialRetryDelayMillis: (entry.initialReconnectDelay ?? ctx.streaming.initialReconnectDelaySeconds) * 1000,
4109
+ logger: ctx.logger,
4110
+ pingHandler: createPingHandler(requestor, sg, ctx.logger),
4111
+ });
4112
+ }
4113
+ /**
4114
+ * Creates a {@link SourceFactoryProvider} that handles `cache`, `polling`,
4115
+ * and `streaming` data source entries.
4116
+ */
4117
+ function createDefaultSourceFactoryProvider() {
4118
+ return {
4119
+ createInitializerFactory(entry, ctx) {
4120
+ switch (entry.type) {
4121
+ case 'polling': {
4122
+ const requestor = resolvePollingRequestor(ctx, entry.endpoints);
4123
+ return (sg) => createPollingInitializer(requestor, ctx.logger, sg);
4124
+ }
4125
+ case 'streaming':
4126
+ return (sg) => createStreamingInitializer(buildStreamingBase(entry, ctx, sg));
4127
+ case 'cache':
4128
+ return createCacheInitializerFactory({
4129
+ storage: ctx.storage,
4130
+ crypto: ctx.crypto,
4131
+ environmentNamespace: ctx.environmentNamespace,
4132
+ context: ctx.context,
4133
+ logger: ctx.logger,
4134
+ });
4135
+ default:
4136
+ return undefined;
4137
+ }
4138
+ },
4139
+ createSynchronizerSlot(entry, ctx) {
4140
+ switch (entry.type) {
4141
+ case 'polling': {
4142
+ const intervalMs = (entry.pollInterval ?? ctx.polling.intervalSeconds) * 1000;
4143
+ const requestor = resolvePollingRequestor(ctx, entry.endpoints);
4144
+ const factory = (sg) => createPollingSynchronizer(requestor, ctx.logger, sg, intervalMs);
4145
+ return createSynchronizerSlot(factory);
4146
+ }
4147
+ case 'streaming': {
4148
+ const factory = (sg) => createStreamingSynchronizer(buildStreamingBase(entry, ctx, sg));
4149
+ return createSynchronizerSlot(factory);
4150
+ }
4151
+ default:
4152
+ return undefined;
4153
+ }
4154
+ },
4155
+ };
4156
+ }
4157
+
4158
+ function flagsToPayload(flags) {
4159
+ const updates = Object.entries(flags).map(([key, flag]) => ({
4160
+ kind: 'flag',
4161
+ key,
4162
+ version: flag.version ?? 1,
4163
+ object: flag,
4164
+ }));
4165
+ return {
4166
+ version: 1,
4167
+ type: 'full',
4168
+ updates,
4169
+ };
4170
+ }
4171
+ /**
4172
+ * Creates a polling synchronizer that polls an FDv1 endpoint and produces
4173
+ * results in the FDv2 {@link Synchronizer} pull model.
4174
+ *
4175
+ * This is a standalone implementation (not a wrapper around the existing FDv1
4176
+ * `PollingProcessor`) because the FDv1 fallback is temporary and will be
4177
+ * removed in the next major version. A focused implementation is simpler than
4178
+ * an adapter layer.
4179
+ *
4180
+ * Polling starts lazily on the first call to `next()`. Each poll returns the
4181
+ * complete flag set as a `changeSet` result with `type: 'full'` and an empty
4182
+ * selector.
4183
+ *
4184
+ * @param requestor FDv1 requestor configured with the appropriate endpoint.
4185
+ * @param pollIntervalMs Interval between polls in milliseconds.
4186
+ * @param logger Optional logger.
4187
+ * @internal
4188
+ */
4189
+ function createFDv1PollingSynchronizer(requestor, pollIntervalMs, logger) {
4190
+ const resultQueue = createAsyncQueue();
4191
+ let shutdownResolve;
4192
+ const shutdownPromise = new Promise((resolve) => {
4193
+ shutdownResolve = resolve;
4194
+ });
4195
+ let timeoutHandle;
4196
+ let stopped = false;
4197
+ let started = false;
4198
+ function scheduleNextPoll(startTime) {
4199
+ if (!stopped) {
4200
+ const elapsed = Date.now() - startTime;
4201
+ const sleepFor = Math.min(Math.max(pollIntervalMs - elapsed, 0), pollIntervalMs);
4202
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
4203
+ timeoutHandle = setTimeout(doPoll, sleepFor);
4204
+ }
4205
+ }
4206
+ async function doPoll() {
4207
+ if (stopped) {
4208
+ return;
4209
+ }
4210
+ logger?.debug('Polling FDv1 endpoint for feature flag updates');
4211
+ const startTime = Date.now();
4212
+ try {
4213
+ const body = await requestor.requestPayload();
4214
+ if (stopped) {
4215
+ return;
4216
+ }
4217
+ let payload;
4218
+ try {
4219
+ const flags = JSON.parse(body);
4220
+ payload = flagsToPayload(flags);
4221
+ }
4222
+ catch {
4223
+ logger?.error('FDv1 polling received malformed data');
4224
+ resultQueue.put({
4225
+ type: 'status',
4226
+ state: 'interrupted',
4227
+ errorInfo: errorInfoFromInvalidData('Malformed data in FDv1 polling response'),
4228
+ fdv1Fallback: false,
4229
+ });
4230
+ scheduleNextPoll(startTime);
4231
+ return;
4232
+ }
4233
+ resultQueue.put(changeSet(payload, false));
4234
+ }
4235
+ catch (err) {
4236
+ if (stopped) {
4237
+ return;
4238
+ }
4239
+ const requestError = err;
4240
+ if (requestError.status !== undefined) {
4241
+ if (!isHttpRecoverable(requestError.status)) {
4242
+ logger?.error(httpErrorMessage(err, 'FDv1 polling request'));
4243
+ stopped = true;
4244
+ shutdownResolve?.(terminalError(errorInfoFromHttpError(requestError.status), false));
4245
+ shutdownResolve = undefined;
4246
+ return;
4247
+ }
4248
+ }
4249
+ logger?.warn(httpErrorMessage(err, 'FDv1 polling request', 'will retry'));
4250
+ resultQueue.put({
4251
+ type: 'status',
4252
+ state: 'interrupted',
4253
+ errorInfo: requestError.status
4254
+ ? errorInfoFromHttpError(requestError.status)
4255
+ : errorInfoFromNetworkError(requestError.message),
4256
+ fdv1Fallback: false,
4257
+ });
4258
+ }
4259
+ scheduleNextPoll(startTime);
4260
+ }
4261
+ return {
4262
+ next() {
4263
+ if (!started) {
4264
+ started = true;
4265
+ doPoll();
4266
+ }
4267
+ return Promise.race([shutdownPromise, resultQueue.take()]);
4268
+ },
4269
+ close() {
4270
+ stopped = true;
4271
+ if (timeoutHandle !== undefined) {
4272
+ clearTimeout(timeoutHandle);
4273
+ timeoutHandle = undefined;
4274
+ }
4275
+ shutdownResolve?.(shutdown());
4276
+ shutdownResolve = undefined;
4277
+ },
4278
+ };
4279
+ }
4280
+
4281
+ const DEFAULT_FALLBACK_TIMEOUT_MS = 2 * 60 * 1000; // 120 seconds
4282
+ const DEFAULT_RECOVERY_TIMEOUT_MS = 5 * 60 * 1000; // 300 seconds
4283
+ /**
4284
+ * Creates a cancelable timer that resolves with the given {@link ConditionType}
4285
+ * when it fires. Wraps {@link cancelableTimedPromise} to convert its
4286
+ * reject-on-timeout semantics into resolve-with-type semantics.
4287
+ */
4288
+ function conditionTimer(timeoutMs, type, taskName) {
4289
+ const timed = cancelableTimedPromise(timeoutMs / 1000, taskName);
4290
+ return {
4291
+ promise: timed.promise.then(() => new Promise(() => { }), // cancelled — never settle
4292
+ () => type),
4293
+ cancel: timed.cancel,
4294
+ };
4295
+ }
4296
+ /**
4297
+ * Creates a timed {@link Condition}.
4298
+ *
4299
+ * @param timeoutMs Time in milliseconds before the condition fires.
4300
+ * @param type The {@link ConditionType} to resolve with when the timer fires.
4301
+ * @param informHandler Optional callback invoked on each `inform()` call.
4302
+ * When omitted, the timer starts immediately and `inform()` is a no-op.
4303
+ * When provided, the timer must be started explicitly via `controls.start()`.
4304
+ */
4305
+ function createCondition(timeoutMs, type, informHandler) {
4306
+ let resolve;
4307
+ let timer;
4308
+ let closed = false;
4309
+ const promise = new Promise((res) => {
4310
+ resolve = res;
4311
+ });
4312
+ function startTimer() {
4313
+ if (!timer && !closed) {
4314
+ timer = conditionTimer(timeoutMs, type, `${type} condition`);
4315
+ timer.promise.then((t) => {
4316
+ timer = undefined;
4317
+ resolve?.(t);
4318
+ });
4319
+ }
4320
+ }
4321
+ function cancelTimer() {
4322
+ timer?.cancel();
4323
+ timer = undefined;
4324
+ }
4325
+ // No inform handler — start immediately (recovery behavior).
4326
+ if (!informHandler) {
4327
+ startTimer();
4328
+ }
4329
+ return {
4330
+ promise,
4331
+ inform(result) {
4332
+ if (closed) {
4333
+ return;
4334
+ }
4335
+ informHandler?.(result, { start: startTimer, cancel: cancelTimer });
4336
+ },
4337
+ close() {
4338
+ closed = true;
4339
+ cancelTimer();
4340
+ },
4341
+ };
4342
+ }
4343
+ /**
4344
+ * Creates a fallback condition. The condition starts a timer when an
4345
+ * `interrupted` status is received and cancels it when a `changeSet` is
4346
+ * received. If the timer fires, the condition resolves with `'fallback'`.
4347
+ */
4348
+ function createFallbackCondition(timeoutMs) {
4349
+ return createCondition(timeoutMs, 'fallback', (result, { start, cancel }) => {
4350
+ if (result.type === 'changeSet') {
4351
+ cancel();
4352
+ }
4353
+ else if (result.type === 'status' && result.state === 'interrupted') {
4354
+ start();
4355
+ }
4356
+ });
4357
+ }
4358
+ /**
4359
+ * Creates a recovery condition. The condition starts a timer immediately
4360
+ * and resolves with `'recovery'` when it fires. It ignores all `inform()`
4361
+ * calls.
4362
+ */
4363
+ function createRecoveryCondition(timeoutMs) {
4364
+ return createCondition(timeoutMs, 'recovery');
4365
+ }
4366
+ /**
4367
+ * Creates a group of conditions that are managed together.
4368
+ *
4369
+ * @param conditions The conditions to group.
4370
+ */
4371
+ function createConditionGroup(conditions) {
4372
+ return {
4373
+ promise: conditions.length === 0 ? undefined : Promise.race(conditions.map((c) => c.promise)),
4374
+ inform(result) {
4375
+ conditions.forEach((condition) => condition.inform(result));
4376
+ },
4377
+ close() {
4378
+ conditions.forEach((condition) => condition.close());
4379
+ },
4380
+ };
4381
+ }
4382
+ /**
4383
+ * Determines which conditions to create based on the synchronizer's position
4384
+ * and availability.
4385
+ *
4386
+ * - If there is only one available synchronizer, no conditions are needed
4387
+ * (there is nowhere to fall back to).
4388
+ * - If the current synchronizer is the primary (first available), only a
4389
+ * fallback condition is created.
4390
+ * - If the current synchronizer is non-primary, both fallback and recovery
4391
+ * conditions are created.
4392
+ *
4393
+ * @param availableSyncCount Number of available (non-blocked) synchronizers.
4394
+ * @param isPrime Whether the current synchronizer is the primary.
4395
+ * @param fallbackTimeoutMs Fallback condition timeout.
4396
+ * @param recoveryTimeoutMs Recovery condition timeout.
4397
+ */
4398
+ function getConditions(availableSyncCount, isPrime, fallbackTimeoutMs = DEFAULT_FALLBACK_TIMEOUT_MS, recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS) {
4399
+ if (availableSyncCount <= 1) {
4400
+ return createConditionGroup([]);
4401
+ }
4402
+ if (isPrime) {
4403
+ return createConditionGroup([createFallbackCondition(fallbackTimeoutMs)]);
4404
+ }
4405
+ return createConditionGroup([
4406
+ createFallbackCondition(fallbackTimeoutMs),
4407
+ createRecoveryCondition(recoveryTimeoutMs),
4408
+ ]);
4409
+ }
4410
+
4411
+ /**
4412
+ * Creates an {@link FDv2DataSource} orchestrator.
4413
+ */
4414
+ function createFDv2DataSource(config) {
4415
+ const { initializerFactories, synchronizerSlots, dataCallback, statusManager, selectorGetter, logger, fallbackTimeoutMs = DEFAULT_FALLBACK_TIMEOUT_MS, recoveryTimeoutMs = DEFAULT_RECOVERY_TIMEOUT_MS, } = config;
4416
+ let initialized = false;
4417
+ let closed = false;
4418
+ let dataReceived = false;
4419
+ let initResolve;
4420
+ let initReject;
4421
+ const sourceManager = createSourceManager(initializerFactories, synchronizerSlots, selectorGetter);
4422
+ function markInitialized() {
4423
+ if (!initialized) {
4424
+ initialized = true;
4425
+ initResolve?.();
4426
+ initResolve = undefined;
4427
+ initReject = undefined;
4428
+ }
4429
+ }
4430
+ function applyChangeSet(result) {
4431
+ dataCallback(result.payload);
4432
+ statusManager.requestStateUpdate('VALID');
4433
+ }
4434
+ function reportStatusError(result) {
4435
+ if (result.errorInfo) {
4436
+ statusManager.reportError(result.errorInfo.kind, result.errorInfo.message, result.errorInfo.statusCode, result.state === 'interrupted');
4437
+ }
4438
+ }
4439
+ function handleFdv1Fallback(result) {
4440
+ if (result.fdv1Fallback && sourceManager.hasFDv1Fallback()) {
4441
+ sourceManager.fdv1Fallback();
4442
+ return true;
4443
+ }
4444
+ return false;
4445
+ }
4446
+ /* eslint-disable no-await-in-loop */
4447
+ // The orchestration loops intentionally use await-in-loop for sequential
4448
+ // state machine processing — one result at a time.
4449
+ async function runInitializers() {
4450
+ while (!closed) {
4451
+ const initializer = sourceManager.getNextInitializerAndSetActive();
4452
+ if (initializer === undefined) {
4453
+ break;
4454
+ }
4455
+ const result = await initializer.run();
4456
+ if (closed) {
4457
+ return;
4458
+ }
4459
+ if (result.type === 'changeSet') {
4460
+ applyChangeSet(result);
4461
+ if (handleFdv1Fallback(result)) {
4462
+ // FDv1 fallback triggered during initialization — data was received
4463
+ // but we should move to synchronizers where the FDv1 adapter will run.
4464
+ dataReceived = true;
4465
+ break;
4466
+ }
4467
+ if (result.payload.state) {
4468
+ // Got basis data with a selector — initialization is complete.
4469
+ markInitialized();
4470
+ return;
4471
+ }
4472
+ // Got data but no selector (e.g., cache). Record that data was
4473
+ // received and continue to the next initializer.
4474
+ dataReceived = true;
4475
+ }
4476
+ else if (result.type === 'status') {
4477
+ switch (result.state) {
4478
+ case 'interrupted':
4479
+ case 'terminal_error':
4480
+ logger?.warn(`Initializer failed: ${result.errorInfo?.message ?? 'unknown error'}`);
4481
+ reportStatusError(result);
4482
+ break;
4483
+ case 'shutdown':
4484
+ return;
4485
+ }
4486
+ handleFdv1Fallback(result);
4487
+ }
4488
+ }
4489
+ // All initializers exhausted.
4490
+ if (dataReceived) {
4491
+ markInitialized();
4492
+ }
4493
+ }
4494
+ async function runSynchronizers() {
4495
+ while (!closed) {
4496
+ const synchronizer = sourceManager.getNextAvailableSynchronizerAndSetActive();
4497
+ if (synchronizer === undefined) {
4498
+ if (!initialized) {
4499
+ initReject?.(new Error('All data sources exhausted without receiving data.'));
4500
+ initResolve = undefined;
4501
+ initReject = undefined;
4502
+ }
4503
+ return;
4504
+ }
4505
+ const conditions = getConditions(sourceManager.getAvailableSynchronizerCount(), sourceManager.isPrimeSynchronizer(), fallbackTimeoutMs, recoveryTimeoutMs);
4506
+ if (conditions.promise) {
4507
+ logger?.debug('Fallback condition active for current synchronizer.');
4508
+ }
4509
+ // try/finally ensures conditions are closed on all code paths.
4510
+ let synchronizerRunning = true;
4511
+ try {
4512
+ while (!closed && synchronizerRunning) {
4513
+ const syncPromise = synchronizer
4514
+ .next()
4515
+ .then((value) => ({ source: 'sync', value }));
4516
+ const racers = [syncPromise];
4517
+ if (conditions.promise !== undefined) {
4518
+ racers.push(conditions.promise.then((value) => ({ source: 'condition', value })));
4519
+ }
4520
+ const winner = await Promise.race(racers);
4521
+ if (closed) {
4522
+ return;
4523
+ }
4524
+ if (winner.source === 'condition') {
4525
+ const conditionType = winner.value;
4526
+ if (conditionType === 'fallback') {
4527
+ logger?.warn('Fallback condition fired, moving to next synchronizer.');
4528
+ }
4529
+ else if (conditionType === 'recovery') {
4530
+ logger?.info('Recovery condition fired, resetting to primary synchronizer.');
4531
+ sourceManager.resetSourceIndex();
4532
+ }
4533
+ synchronizerRunning = false;
4534
+ }
4535
+ else {
4536
+ // Synchronizer produced a result.
4537
+ const syncResult = winner.value;
4538
+ conditions.inform(syncResult);
4539
+ if (syncResult.type === 'changeSet') {
4540
+ applyChangeSet(syncResult);
4541
+ if (!initialized) {
4542
+ markInitialized();
4543
+ }
4544
+ }
4545
+ else if (syncResult.type === 'status') {
4546
+ switch (syncResult.state) {
4547
+ case 'interrupted':
4548
+ logger?.warn(`Synchronizer interrupted: ${syncResult.errorInfo?.message ?? 'unknown error'}`);
4549
+ reportStatusError(syncResult);
4550
+ break;
4551
+ case 'terminal_error':
4552
+ logger?.error(`Synchronizer terminal error: ${syncResult.errorInfo?.message ?? 'unknown error'}`);
4553
+ reportStatusError(syncResult);
4554
+ sourceManager.blockCurrentSynchronizer();
4555
+ synchronizerRunning = false;
4556
+ break;
4557
+ case 'shutdown':
4558
+ return;
4559
+ case 'goodbye':
4560
+ // The synchronizer will handle reconnection internally.
4561
+ break;
4562
+ default:
4563
+ break;
4564
+ }
4565
+ }
4566
+ // Check for FDv1 fallback after all result handling — single location.
4567
+ if (handleFdv1Fallback(syncResult)) {
4568
+ synchronizerRunning = false;
4569
+ }
4570
+ }
4571
+ }
4572
+ }
4573
+ finally {
4574
+ conditions.close();
4575
+ }
4576
+ }
4577
+ }
4578
+ /* eslint-enable no-await-in-loop */
4579
+ async function runOrchestration() {
4580
+ // No sources configured at all — nothing to wait for, immediately valid.
4581
+ if (initializerFactories.length === 0 && synchronizerSlots.length === 0) {
4582
+ statusManager.requestStateUpdate('VALID');
4583
+ markInitialized();
4584
+ return;
4585
+ }
4586
+ await runInitializers();
4587
+ if (!closed) {
4588
+ await runSynchronizers();
4589
+ }
4590
+ }
4591
+ return {
4592
+ start() {
4593
+ return new Promise((resolve, reject) => {
4594
+ initResolve = resolve;
4595
+ initReject = reject;
4596
+ statusManager.requestStateUpdate('INITIALIZING');
4597
+ runOrchestration()
4598
+ .then(() => {
4599
+ // Orchestration completed without error. If the init promise was
4600
+ // never resolved (e.g., close() called during init, or all sources
4601
+ // exhausted without data and no synchronizers), resolve it now.
4602
+ // This prevents the start() promise from hanging forever.
4603
+ if (!initialized) {
4604
+ initReject?.(new Error('Data source closed before initialization completed.'));
4605
+ initResolve = undefined;
4606
+ initReject = undefined;
4607
+ }
4608
+ })
4609
+ .catch((err) => {
4610
+ if (!initialized) {
4611
+ initReject?.(err instanceof Error ? err : new Error(String(err)));
4612
+ initResolve = undefined;
4613
+ initReject = undefined;
4614
+ }
4615
+ else {
4616
+ logger?.error(`Orchestration error: ${err}`);
4617
+ }
4618
+ });
4619
+ });
4620
+ },
4621
+ close() {
4622
+ closed = true;
4623
+ sourceManager.close();
4624
+ },
4625
+ };
4626
+ }
4627
+
4628
+ /** Default debounce window duration in milliseconds. */
4629
+ const DEFAULT_DEBOUNCE_MS = 1000;
4630
+ /**
4631
+ * Creates a {@link StateDebounceManager}.
4632
+ *
4633
+ * The manager accumulates state changes from network, lifecycle, and
4634
+ * connection mode events. Each event updates the relevant component
4635
+ * of the pending state and resets the debounce timer. When the timer
4636
+ * fires, the reconciliation callback receives the final combined state.
4637
+ *
4638
+ * @param config Configuration for the debounce manager.
4639
+ */
4640
+ function createStateDebounceManager(config) {
4641
+ const { initialState, onReconcile, debounceMs = DEFAULT_DEBOUNCE_MS } = config;
4642
+ let { networkState, lifecycleState, requestedMode } = initialState;
4643
+ let timer;
4644
+ let closed = false;
4645
+ function getPendingState() {
4646
+ return { networkState, lifecycleState, requestedMode };
4647
+ }
4648
+ function resetTimer() {
4649
+ if (closed) {
4650
+ return;
4651
+ }
4652
+ if (timer !== undefined) {
4653
+ clearTimeout(timer);
4654
+ }
4655
+ timer = setTimeout(() => {
4656
+ timer = undefined;
4657
+ if (!closed) {
4658
+ onReconcile(getPendingState());
4659
+ }
4660
+ }, debounceMs);
4661
+ }
4662
+ return {
4663
+ setNetworkState(state) {
4664
+ if (networkState === state) {
4665
+ return;
4666
+ }
4667
+ networkState = state;
4668
+ resetTimer();
4669
+ },
4670
+ setLifecycleState(state) {
4671
+ if (lifecycleState === state) {
4672
+ return;
4673
+ }
4674
+ lifecycleState = state;
4675
+ resetTimer();
4676
+ },
4677
+ setRequestedMode(mode) {
4678
+ if (requestedMode === mode) {
4679
+ return;
4680
+ }
4681
+ requestedMode = mode;
4682
+ resetTimer();
4683
+ },
4684
+ close() {
4685
+ closed = true;
4686
+ if (timer !== undefined) {
4687
+ clearTimeout(timer);
4688
+ timer = undefined;
4689
+ }
4690
+ },
4691
+ };
4692
+ }
4693
+
4694
+ const logTag = '[FDv2DataManagerBase]';
4695
+ /**
4696
+ * Creates a shared FDv2 data manager that owns mode resolution, debouncing,
4697
+ * selector state, and FDv2DataSource lifecycle. Platform SDKs (browser, RN)
4698
+ * wrap this with platform-specific config and event wiring.
4699
+ */
4700
+ function createFDv2DataManagerBase(baseConfig) {
4701
+ const { platform, flagManager, config, baseHeaders, emitter, transitionTable, foregroundMode: configuredForegroundMode, backgroundMode, modeTable, sourceFactoryProvider, buildQueryParams, fdv1Endpoints, fallbackTimeoutMs, recoveryTimeoutMs, } = baseConfig;
4702
+ const { logger } = config;
4703
+ const statusManager = createDataSourceStatusManager(emitter);
4704
+ const endpoints = fdv2Endpoints();
4705
+ // Merge user-provided connection mode overrides into the mode table.
4706
+ const effectiveModeTable = config.dataSystem?.connectionModes
4707
+ ? { ...modeTable, ...config.dataSystem.connectionModes }
4708
+ : modeTable;
4709
+ // --- Mutable state ---
4710
+ let selector;
4711
+ let currentResolvedMode = configuredForegroundMode;
4712
+ let foregroundMode = configuredForegroundMode;
4713
+ let dataSource;
4714
+ let debounceManager;
4715
+ let identifiedContext;
4716
+ let factoryContext;
4717
+ let initialized = false;
4718
+ let bootstrapped = false;
4719
+ let closed = false;
4720
+ let flushCallback;
4721
+ // Explicit connection mode override — bypasses transition table entirely.
4722
+ let connectionModeOverride;
4723
+ // Forced/automatic streaming state for browser listener-driven streaming.
4724
+ let forcedStreaming;
4725
+ let automaticStreamingState = false;
4726
+ // Outstanding identify promise callbacks — needed so that mode switches
4727
+ // during identify can wire the new data source's completion to the
4728
+ // original identify promise.
4729
+ let pendingIdentifyResolve;
4730
+ let pendingIdentifyReject;
4731
+ // Current debounce input state.
4732
+ let networkState = 'available';
4733
+ let lifecycleState = 'foreground';
4734
+ // --- Helpers ---
4735
+ function getModeDefinition(mode) {
4736
+ return effectiveModeTable[mode];
4737
+ }
4738
+ function buildModeState() {
4739
+ return {
4740
+ lifecycle: lifecycleState,
4741
+ networkAvailable: networkState === 'available',
4742
+ foregroundMode,
4743
+ backgroundMode: backgroundMode ?? 'offline',
4744
+ };
4745
+ }
4746
+ /**
4747
+ * Resolve the current effective connection mode.
4748
+ *
4749
+ * Priority:
4750
+ * 1. connectionModeOverride (set via setConnectionMode) — bypasses everything
4751
+ * 2. Transition table (network/lifecycle state + foreground/background modes)
4752
+ */
4753
+ function resolveMode() {
4754
+ if (connectionModeOverride !== undefined) {
4755
+ return connectionModeOverride;
4756
+ }
4757
+ return resolveConnectionMode(transitionTable, buildModeState());
4758
+ }
4759
+ /**
4760
+ * Resolve the foreground mode input for the transition table based on
4761
+ * forced/automatic streaming state.
4762
+ *
4763
+ * Priority: forcedStreaming > automaticStreaming > configuredForegroundMode
4764
+ */
4765
+ function resolveStreamingForeground() {
4766
+ if (forcedStreaming === true) {
4767
+ return 'streaming';
4768
+ }
4769
+ if (forcedStreaming === false) {
4770
+ return configuredForegroundMode === 'streaming' ? 'one-shot' : configuredForegroundMode;
4771
+ }
4772
+ return automaticStreamingState ? 'streaming' : configuredForegroundMode;
4773
+ }
4774
+ /**
4775
+ * Compute the effective foreground mode from streaming state and push it
4776
+ * through the debounce manager. Used by setForcedStreaming and
4777
+ * setAutomaticStreamingState.
4778
+ */
4779
+ function pushForegroundMode() {
4780
+ foregroundMode = resolveStreamingForeground();
4781
+ debounceManager?.setRequestedMode(foregroundMode);
4782
+ }
4783
+ /**
4784
+ * Convert a ModeDefinition's entries into concrete InitializerFactory[]
4785
+ * and SynchronizerSlot[] using the source factory provider.
4786
+ */
4787
+ function buildFactories(modeDef, ctx, includeInitializers) {
4788
+ const initializerFactories = [];
4789
+ if (includeInitializers) {
4790
+ modeDef.initializers
4791
+ // Skip cache when bootstrapped — bootstrap data was applied to the
4792
+ // flag store before identify, so the cache would only load older data.
4793
+ .filter((entry) => !(bootstrapped && entry.type === 'cache'))
4794
+ .forEach((entry) => {
4795
+ const factory = sourceFactoryProvider.createInitializerFactory(entry, ctx);
4796
+ if (factory) {
4797
+ initializerFactories.push(factory);
4798
+ }
4799
+ else {
4800
+ logger.warn(`${logTag} Unsupported initializer type '${entry.type}'. It will be skipped.`);
4801
+ }
4802
+ });
4803
+ }
4804
+ const synchronizerSlots = [];
4805
+ modeDef.synchronizers.forEach((entry) => {
4806
+ const slot = sourceFactoryProvider.createSynchronizerSlot(entry, ctx);
4807
+ if (slot) {
4808
+ synchronizerSlots.push(slot);
4809
+ }
4810
+ else {
4811
+ logger.warn(`${logTag} Unsupported synchronizer type '${entry.type}'. It will be skipped.`);
4812
+ }
4813
+ });
4814
+ // Append a blocked FDv1 fallback synchronizer when configured and
4815
+ // when there are FDv2 synchronizers to fall back from.
4816
+ if (fdv1Endpoints && synchronizerSlots.length > 0) {
4817
+ const fallbackConfig = modeDef.fdv1Fallback;
4818
+ const fallbackPollIntervalMs = (fallbackConfig?.pollInterval ?? config.pollInterval) * 1000;
4819
+ const fallbackServiceEndpoints = fallbackConfig?.endpoints?.pollingBaseUri || fallbackConfig?.endpoints?.streamingBaseUri
4820
+ ? new ServiceEndpoints(fallbackConfig.endpoints.streamingBaseUri ?? ctx.serviceEndpoints.streaming, fallbackConfig.endpoints.pollingBaseUri ?? ctx.serviceEndpoints.polling, ctx.serviceEndpoints.events, ctx.serviceEndpoints.analyticsEventPath, ctx.serviceEndpoints.diagnosticEventPath, ctx.serviceEndpoints.includeAuthorizationHeader, ctx.serviceEndpoints.payloadFilterKey)
4821
+ : ctx.serviceEndpoints;
4822
+ const fdv1RequestorFactory = () => makeRequestor(ctx.plainContextString, fallbackServiceEndpoints, fdv1Endpoints.polling(), ctx.requests, ctx.encoding, ctx.baseHeaders, ctx.queryParams, config.withReasons, config.useReport);
4823
+ const fdv1SyncFactory = () => createFDv1PollingSynchronizer(fdv1RequestorFactory(), fallbackPollIntervalMs, logger);
4824
+ synchronizerSlots.push(createSynchronizerSlot(fdv1SyncFactory, { isFDv1Fallback: true }));
4825
+ }
4826
+ return { initializerFactories, synchronizerSlots };
4827
+ }
4828
+ /**
4829
+ * The data callback shared across all FDv2DataSource instances for
4830
+ * the current identify. Handles selector tracking and flag updates.
4831
+ */
4832
+ function dataCallback(payload) {
4833
+ logger.debug(`${logTag} dataCallback: type=${payload.type}, updates=${payload.updates.length}, state=${payload.state}`);
4834
+ selector = payload.state;
4835
+ const context = identifiedContext;
4836
+ if (!context) {
4837
+ logger.warn(`${logTag} dataCallback called without an identified context.`);
4838
+ return;
4839
+ }
4840
+ const descriptors = flagEvalPayloadToItemDescriptors(payload.updates ?? []);
4841
+ // Flag updates and change events happen synchronously inside applyChanges.
4842
+ // The returned promise is only for async cache persistence — we intentionally
4843
+ // do not await it so the data source pipeline is not blocked by storage I/O.
4844
+ flagManager.applyChanges(context, descriptors, payload.type).catch((e) => {
4845
+ logger.warn(`${logTag} Failed to persist flag cache: ${e}`);
4846
+ });
4847
+ }
4848
+ /**
4849
+ * Create and start a new FDv2DataSource for the given mode.
4850
+ *
4851
+ * @param mode The connection mode to use.
4852
+ * @param includeInitializers Whether to include initializers (true on
4853
+ * first identify, false on mode switch after initialization).
4854
+ */
4855
+ function createAndStartDataSource(mode, includeInitializers) {
4856
+ if (!factoryContext) {
4857
+ logger.warn(`${logTag} Cannot create data source without factory context.`);
4858
+ return;
4859
+ }
4860
+ const modeDef = getModeDefinition(mode);
4861
+ const { initializerFactories, synchronizerSlots } = buildFactories(modeDef, factoryContext, includeInitializers);
4862
+ currentResolvedMode = mode;
4863
+ // If there are no sources at all (e.g., offline or one-shot mode
4864
+ // post-initialization), don't create a data source.
4865
+ if (initializerFactories.length === 0 && synchronizerSlots.length === 0) {
4866
+ logger.debug(`${logTag} Mode '${mode}' has no sources. No data source created.`);
4867
+ if (!initialized && pendingIdentifyResolve) {
4868
+ // Offline mode during initial identify — resolve immediately.
4869
+ // The SDK will use cached data if any.
4870
+ initialized = true;
4871
+ pendingIdentifyResolve();
4872
+ pendingIdentifyResolve = undefined;
4873
+ pendingIdentifyReject = undefined;
4874
+ }
4875
+ return;
4876
+ }
4877
+ const selectorGetter = () => selector;
4878
+ dataSource = createFDv2DataSource({
4879
+ initializerFactories,
4880
+ synchronizerSlots,
4881
+ dataCallback,
4882
+ statusManager,
4883
+ selectorGetter,
4884
+ logger,
4885
+ fallbackTimeoutMs,
4886
+ recoveryTimeoutMs,
4887
+ });
4888
+ dataSource
4889
+ .start()
4890
+ .then(() => {
4891
+ initialized = true;
4892
+ if (pendingIdentifyResolve) {
4893
+ pendingIdentifyResolve();
4894
+ pendingIdentifyResolve = undefined;
4895
+ pendingIdentifyReject = undefined;
4896
+ }
4897
+ })
4898
+ .catch((err) => {
4899
+ if (pendingIdentifyReject) {
4900
+ pendingIdentifyReject(err instanceof Error ? err : new Error(String(err)));
4901
+ pendingIdentifyResolve = undefined;
4902
+ pendingIdentifyReject = undefined;
4903
+ }
4904
+ });
4905
+ }
4906
+ /**
4907
+ * Reconciliation callback invoked when the debounce timer fires.
4908
+ * Resolves the new mode and switches data sources if needed.
4909
+ */
4910
+ function onReconcile(pendingState) {
4911
+ if (closed || !factoryContext) {
4912
+ return;
4913
+ }
4914
+ // Update local state from the debounced pending state.
4915
+ networkState = pendingState.networkState;
4916
+ lifecycleState = pendingState.lifecycleState;
4917
+ foregroundMode = pendingState.requestedMode;
4918
+ const newMode = resolveMode();
4919
+ if (newMode === currentResolvedMode) {
4920
+ logger.debug(`${logTag} Reconcile: mode unchanged (${newMode}). No action.`);
4921
+ return;
4922
+ }
4923
+ logger.debug(`${logTag} Reconcile: mode switching from '${currentResolvedMode}' to '${newMode}'.`);
4924
+ // Close the current data source.
4925
+ dataSource?.close();
4926
+ dataSource = undefined;
4927
+ // Include initializers if we don't have a selector yet. This covers:
4928
+ // - Not yet initialized (normal case)
4929
+ // - Initialized from bootstrap (no selector) — need initializers to
4930
+ // get a full payload via poll before starting synchronizers
4931
+ // When we have a selector, only synchronizers change (spec 5.3.8).
4932
+ const includeInitializers = !selector;
4933
+ createAndStartDataSource(newMode, includeInitializers);
4934
+ }
4935
+ // --- Public interface ---
4936
+ return {
4937
+ get configuredForegroundMode() {
4938
+ return configuredForegroundMode;
4939
+ },
4940
+ async identify(identifyResolve, identifyReject, context, identifyOptions) {
4941
+ if (closed) {
4942
+ logger.debug(`${logTag} Identify called after close.`);
4943
+ return;
4944
+ }
4945
+ // Tear down previous state.
4946
+ dataSource?.close();
4947
+ dataSource = undefined;
4948
+ debounceManager?.close();
4949
+ debounceManager = undefined;
4950
+ selector = undefined;
4951
+ initialized = false;
4952
+ bootstrapped = false;
4953
+ identifiedContext = context;
4954
+ pendingIdentifyResolve = identifyResolve;
4955
+ pendingIdentifyReject = identifyReject;
4956
+ const plainContextString = JSON.stringify(Context.toLDContext(context));
4957
+ const queryParams = buildQueryParams(identifyOptions);
4958
+ if (config.withReasons) {
4959
+ queryParams.push({ key: 'withReasons', value: 'true' });
4960
+ }
4961
+ const streamingEndpoints = endpoints.streaming();
4962
+ const pollingEndpoints = endpoints.polling();
4963
+ const requestor = makeFDv2Requestor(plainContextString, config.serviceEndpoints, pollingEndpoints, platform.requests, platform.encoding, baseHeaders, queryParams);
4964
+ const environmentNamespace = await namespaceForEnvironment(platform.crypto, baseConfig.credential);
4965
+ // Re-check after the await — close() may have been called while
4966
+ // namespaceForEnvironment was pending.
4967
+ if (closed) {
4968
+ logger.debug(`${logTag} Identify aborted: closed during async setup.`);
4969
+ return;
4970
+ }
4971
+ factoryContext = {
4972
+ requestor,
4973
+ requests: platform.requests,
4974
+ encoding: platform.encoding,
4975
+ serviceEndpoints: config.serviceEndpoints,
4976
+ baseHeaders,
4977
+ queryParams,
4978
+ plainContextString,
4979
+ logger,
4980
+ polling: {
4981
+ paths: pollingEndpoints,
4982
+ intervalSeconds: config.pollInterval,
4983
+ },
4984
+ streaming: {
4985
+ paths: streamingEndpoints,
4986
+ initialReconnectDelaySeconds: config.streamInitialReconnectDelay,
4987
+ },
4988
+ storage: platform.storage,
4989
+ crypto: platform.crypto,
4990
+ environmentNamespace,
4991
+ context,
4992
+ };
4993
+ // Ensure foreground mode reflects current streaming state before resolving.
4994
+ foregroundMode = resolveStreamingForeground();
4995
+ // Resolve the initial mode.
4996
+ const mode = resolveMode();
4997
+ logger.debug(`${logTag} Identify: initial mode resolved to '${mode}'.`);
4998
+ bootstrapped = identifyOptions?.bootstrap !== undefined;
4999
+ if (bootstrapped) {
5000
+ // Bootstrap data was already applied to the flag store by the
5001
+ // caller (BrowserClient.start → presetFlags) before identify
5002
+ // was called. Resolve immediately — flag evaluations will use
5003
+ // the bootstrap data synchronously.
5004
+ initialized = true;
5005
+ statusManager.requestStateUpdate('VALID');
5006
+ // selector remains undefined — bootstrap data has no selector.
5007
+ pendingIdentifyResolve?.();
5008
+ pendingIdentifyResolve = undefined;
5009
+ pendingIdentifyReject = undefined;
5010
+ // Only create a data source if the mode has synchronizers.
5011
+ // For one-shot (no synchronizers), there's nothing more to do.
5012
+ const modeDef = getModeDefinition(mode);
5013
+ if (modeDef.synchronizers.length > 0) {
5014
+ // Start synchronizers without initializers — we already have
5015
+ // data from bootstrap. Initializers will run on mode switches
5016
+ // if selector is still undefined (see onReconcile).
5017
+ createAndStartDataSource(mode, false);
5018
+ }
5019
+ }
5020
+ else {
5021
+ // Normal identify — create and start the data source with full pipeline.
5022
+ createAndStartDataSource(mode, true);
5023
+ }
5024
+ // Set up debouncing for subsequent state changes.
5025
+ debounceManager = createStateDebounceManager({
5026
+ initialState: {
5027
+ networkState,
5028
+ lifecycleState,
5029
+ requestedMode: foregroundMode,
5030
+ },
5031
+ onReconcile,
5032
+ });
5033
+ },
5034
+ close() {
5035
+ closed = true;
5036
+ dataSource?.close();
5037
+ dataSource = undefined;
5038
+ debounceManager?.close();
5039
+ debounceManager = undefined;
5040
+ pendingIdentifyResolve = undefined;
5041
+ pendingIdentifyReject = undefined;
5042
+ },
5043
+ setNetworkState(state) {
5044
+ networkState = state;
5045
+ debounceManager?.setNetworkState(state);
5046
+ },
5047
+ setLifecycleState(state) {
5048
+ // Flush immediately when going to background — the app may be
5049
+ // about to close. This is not debounced (CONNMODE spec 3.3.1).
5050
+ if (state === 'background' && lifecycleState !== 'background') {
5051
+ flushCallback?.();
5052
+ }
5053
+ lifecycleState = state;
5054
+ debounceManager?.setLifecycleState(state);
5055
+ },
5056
+ setConnectionMode(mode) {
5057
+ connectionModeOverride = mode;
5058
+ if (mode !== undefined) {
5059
+ debounceManager?.setRequestedMode(mode);
5060
+ }
5061
+ else {
5062
+ pushForegroundMode();
5063
+ }
5064
+ },
5065
+ getCurrentMode() {
5066
+ return currentResolvedMode;
5067
+ },
5068
+ setFlushCallback(callback) {
5069
+ flushCallback = callback;
5070
+ },
5071
+ setForcedStreaming(streaming) {
5072
+ forcedStreaming = streaming;
5073
+ pushForegroundMode();
5074
+ },
5075
+ setAutomaticStreamingState(streaming) {
5076
+ automaticStreamingState = streaming;
5077
+ pushForegroundMode();
5078
+ },
5079
+ };
5080
+ }
5081
+
5082
+ export { BROWSER_DATA_SYSTEM_DEFAULTS, BROWSER_TRANSITION_TABLE, BaseDataManager, DESKTOP_DATA_SYSTEM_DEFAULTS, DESKTOP_TRANSITION_TABLE, DataSourceState, LDClientImpl, MOBILE_DATA_SYSTEM_DEFAULTS, MOBILE_TRANSITION_TABLE, MODE_TABLE, browserFdv1Endpoints, createDataSourceStatusManager, createDefaultSourceFactoryProvider, createFDv2DataManagerBase, createStateDebounceManager, dataSystemValidators, fdv2Endpoints, makeRequestor, mobileFdv1Endpoints, readFlagsFromBootstrap, resolveConnectionMode, resolveForegroundMode, safeRegisterDebugOverridePlugins, validateOptions };
2830
5083
  //# sourceMappingURL=index.mjs.map