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