@launchdarkly/js-client-sdk-common 1.23.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 (62) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cjs/DataManager.d.ts +9 -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 +46 -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 +37 -41
  11. package/dist/cjs/api/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  12. package/dist/cjs/api/datasource/ModeDefinition.d.ts +17 -1
  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 +37 -3
  27. package/dist/cjs/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  28. package/dist/cjs/index.cjs +1184 -52
  29. package/dist/cjs/index.cjs.map +1 -1
  30. package/dist/cjs/index.d.ts +7 -2
  31. package/dist/cjs/index.d.ts.map +1 -1
  32. package/dist/esm/DataManager.d.ts +9 -0
  33. package/dist/esm/DataManager.d.ts.map +1 -1
  34. package/dist/esm/api/LDOptions.d.ts +17 -5
  35. package/dist/esm/api/LDOptions.d.ts.map +1 -1
  36. package/dist/esm/api/datasource/DataSourceEntry.d.ts +46 -0
  37. package/dist/esm/api/datasource/DataSourceEntry.d.ts.map +1 -1
  38. package/dist/esm/api/datasource/FDv2ConnectionMode.d.ts +5 -0
  39. package/dist/esm/api/datasource/FDv2ConnectionMode.d.ts.map +1 -1
  40. package/dist/esm/api/datasource/LDClientDataSystemOptions.d.ts +37 -41
  41. package/dist/esm/api/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  42. package/dist/esm/api/datasource/ModeDefinition.d.ts +17 -1
  43. package/dist/esm/api/datasource/ModeDefinition.d.ts.map +1 -1
  44. package/dist/esm/api/datasource/index.d.ts +2 -2
  45. package/dist/esm/api/datasource/index.d.ts.map +1 -1
  46. package/dist/esm/configuration/Configuration.d.ts +3 -3
  47. package/dist/esm/configuration/Configuration.d.ts.map +1 -1
  48. package/dist/esm/configuration/validateOptions.d.ts +18 -2
  49. package/dist/esm/configuration/validateOptions.d.ts.map +1 -1
  50. package/dist/esm/configuration/validators.d.ts +1 -1
  51. package/dist/esm/configuration/validators.d.ts.map +1 -1
  52. package/dist/esm/datasource/ConnectionModeConfig.d.ts +4 -2
  53. package/dist/esm/datasource/ConnectionModeConfig.d.ts.map +1 -1
  54. package/dist/esm/datasource/FDv2DataManagerBase.d.ts +87 -0
  55. package/dist/esm/datasource/FDv2DataManagerBase.d.ts.map +1 -0
  56. package/dist/esm/datasource/LDClientDataSystemOptions.d.ts +37 -3
  57. package/dist/esm/datasource/LDClientDataSystemOptions.d.ts.map +1 -1
  58. package/dist/esm/index.d.ts +7 -2
  59. package/dist/esm/index.d.ts.map +1 -1
  60. package/dist/esm/index.mjs +1183 -54
  61. package/dist/esm/index.mjs.map +1 -1
  62. 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)) {
@@ -404,6 +412,10 @@ function anyOf(...validators) {
404
412
  *
405
413
  * @param keyValidator - Validates that each key is an allowed value.
406
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}).
407
419
  *
408
420
  * @example
409
421
  * ```ts
@@ -415,7 +427,8 @@ function anyOf(...validators) {
415
427
  * )
416
428
  * ```
417
429
  */
418
- function recordOf(keyValidator, valueValidator) {
430
+ function recordOf(keyValidator, valueValidator, options) {
431
+ const builtInDefaults = options?.defaults;
419
432
  return {
420
433
  is: (u) => jsSdkCommon.TypeValidators.Object.is(u),
421
434
  getType: () => 'object',
@@ -440,14 +453,14 @@ function recordOf(keyValidator, valueValidator) {
440
453
  logger?.warn(jsSdkCommon.OptionMessages.wrongOptionType(name, keyValidator.getType(), key));
441
454
  }
442
455
  });
443
- const recordDefaults = jsSdkCommon.TypeValidators.Object.is(defaults)
444
- ? defaults
445
- : {};
456
+ const recordDefaults = builtInDefaults ??
457
+ (jsSdkCommon.TypeValidators.Object.is(defaults) ? defaults : {});
446
458
  return { value: validateOptions(filtered, validatorMap, recordDefaults, logger, name) };
447
459
  },
448
460
  };
449
461
  }
450
462
 
463
+ const DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS = 300;
451
464
  const BACKGROUND_POLL_INTERVAL_SECONDS = 3600;
452
465
  const dataSourceTypeValidator = jsSdkCommon.TypeValidators.oneOf('cache', 'polling', 'streaming');
453
466
  const connectionModeValidator = jsSdkCommon.TypeValidators.oneOf('streaming', 'polling', 'offline', 'one-shot', 'background');
@@ -477,19 +490,25 @@ const synchronizerEntryArrayValidator = arrayOf('type', {
477
490
  polling: pollingEntryValidators,
478
491
  streaming: streamingEntryValidators,
479
492
  });
493
+ const fdv1FallbackValidators = {
494
+ pollInterval: jsSdkCommon.TypeValidators.numberWithMin(30),
495
+ endpoints: validatorOf(endpointValidators),
496
+ };
480
497
  const modeDefinitionValidators = {
481
498
  initializers: initializerEntryArrayValidator,
482
499
  synchronizers: synchronizerEntryArrayValidator,
500
+ fdv1Fallback: validatorOf(fdv1FallbackValidators),
483
501
  };
484
- const connectionModesValidator = recordOf(connectionModeValidator, validatorOf(modeDefinitionValidators));
485
502
  const MODE_TABLE = {
486
503
  streaming: {
487
504
  initializers: [{ type: 'cache' }, { type: 'polling' }],
488
505
  synchronizers: [{ type: 'streaming' }, { type: 'polling' }],
506
+ fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
489
507
  },
490
508
  polling: {
491
509
  initializers: [{ type: 'cache' }],
492
510
  synchronizers: [{ type: 'polling' }],
511
+ fdv1Fallback: { pollInterval: DEFAULT_FDV1_FALLBACK_POLL_INTERVAL_SECONDS },
493
512
  },
494
513
  offline: {
495
514
  initializers: [{ type: 'cache' }],
@@ -502,24 +521,33 @@ const MODE_TABLE = {
502
521
  background: {
503
522
  initializers: [{ type: 'cache' }],
504
523
  synchronizers: [{ type: 'polling', pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS }],
524
+ fdv1Fallback: { pollInterval: BACKGROUND_POLL_INTERVAL_SECONDS },
505
525
  },
506
526
  };
527
+ const connectionModesValidator = recordOf(connectionModeValidator, validatorOf(modeDefinitionValidators));
507
528
 
508
- 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'),
509
534
  lifecycle: jsSdkCommon.TypeValidators.Boolean,
510
535
  network: jsSdkCommon.TypeValidators.Boolean,
511
536
  };
512
- const dataSystemValidators = {
537
+ const manualModeValidators = {
538
+ type: jsSdkCommon.TypeValidators.oneOf('manual'),
513
539
  initialConnectionMode: connectionModeValidator,
540
+ };
541
+ const dataSystemValidators = {
514
542
  backgroundConnectionMode: connectionModeValidator,
515
- 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') })),
516
544
  connectionModes: connectionModesValidator,
517
545
  };
518
546
  /**
519
547
  * Default FDv2 data system configuration for browser SDKs.
520
548
  */
521
549
  const BROWSER_DATA_SYSTEM_DEFAULTS = {
522
- initialConnectionMode: 'one-shot',
550
+ foregroundConnectionMode: 'one-shot',
523
551
  backgroundConnectionMode: undefined,
524
552
  automaticModeSwitching: false,
525
553
  };
@@ -527,7 +555,7 @@ const BROWSER_DATA_SYSTEM_DEFAULTS = {
527
555
  * Default FDv2 data system configuration for mobile (React Native) SDKs.
528
556
  */
529
557
  const MOBILE_DATA_SYSTEM_DEFAULTS = {
530
- initialConnectionMode: 'streaming',
558
+ foregroundConnectionMode: 'streaming',
531
559
  backgroundConnectionMode: 'background',
532
560
  automaticModeSwitching: true,
533
561
  };
@@ -535,10 +563,24 @@ const MOBILE_DATA_SYSTEM_DEFAULTS = {
535
563
  * Default FDv2 data system configuration for desktop SDKs (Electron, etc.).
536
564
  */
537
565
  const DESKTOP_DATA_SYSTEM_DEFAULTS = {
538
- initialConnectionMode: 'streaming',
566
+ foregroundConnectionMode: 'streaming',
539
567
  backgroundConnectionMode: undefined,
540
568
  automaticModeSwitching: false,
541
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
+ }
542
584
 
543
585
  function createValidators(options) {
544
586
  return {
@@ -568,7 +610,12 @@ function createValidators(options) {
568
610
  inspectors: jsSdkCommon.TypeValidators.createTypeArray('LDInspection', {}),
569
611
  cleanOldPersistentData: jsSdkCommon.TypeValidators.Boolean,
570
612
  dataSystem: options?.dataSystemDefaults
571
- ? validatorOf(dataSystemValidators, options.dataSystemDefaults)
613
+ ? validatorOf(dataSystemValidators, {
614
+ defaults: {
615
+ ...options.dataSystemDefaults,
616
+ connectionModes: MODE_TABLE,
617
+ },
618
+ })
572
619
  : jsSdkCommon.TypeValidators.Object,
573
620
  };
574
621
  }
@@ -3175,7 +3222,6 @@ async function loadFromCache(config) {
3175
3222
  object: flagToEvaluationResult(flag),
3176
3223
  }));
3177
3224
  const payload = {
3178
- id: 'cache',
3179
3225
  version: 0,
3180
3226
  // No `state` field. The orchestrator sees a changeSet without a selector,
3181
3227
  // records dataReceived=true, and continues to the next initializer.
@@ -3279,6 +3325,51 @@ function makeFDv2Requestor(plainContextString, serviceEndpoints, paths, requests
3279
3325
  function processFlagEval(object) {
3280
3326
  return object;
3281
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
+ }
3282
3373
 
3283
3374
  function getFallback(headers) {
3284
3375
  const value = headers.get('x-ld-fd-fallback');
@@ -3295,7 +3386,7 @@ function getEnvironmentId(headers) {
3295
3386
  * it only forwards payloads and actionable errors. For polling results,
3296
3387
  * we need full control over all protocol action types.
3297
3388
  */
3298
- function processEvents(events, oneShot, fdv1Fallback, environmentId, logger) {
3389
+ function processEvents(events, fdv1Fallback, environmentId, logger) {
3299
3390
  const handler = jsSdkCommon.internal.createProtocolHandler({
3300
3391
  'flag-eval': processFlagEval,
3301
3392
  }, logger);
@@ -3315,9 +3406,7 @@ function processEvents(events, oneShot, fdv1Fallback, environmentId, logger) {
3315
3406
  case 'serverError': {
3316
3407
  const errorInfo = errorInfoFromUnknown(action.reason);
3317
3408
  logger?.error(`Server error during polling: ${action.reason}`);
3318
- earlyResult = oneShot
3319
- ? terminalError(errorInfo, fdv1Fallback)
3320
- : interrupted(errorInfo, fdv1Fallback);
3409
+ earlyResult = interrupted(errorInfo, fdv1Fallback);
3321
3410
  break;
3322
3411
  }
3323
3412
  case 'error': {
@@ -3325,9 +3414,7 @@ function processEvents(events, oneShot, fdv1Fallback, environmentId, logger) {
3325
3414
  if (action.kind === 'MISSING_PAYLOAD' || action.kind === 'PROTOCOL_ERROR') {
3326
3415
  const errorInfo = errorInfoFromInvalidData(action.message);
3327
3416
  logger?.warn(`Protocol error during polling: ${action.message}`);
3328
- earlyResult = oneShot
3329
- ? terminalError(errorInfo, fdv1Fallback)
3330
- : interrupted(errorInfo, fdv1Fallback);
3417
+ earlyResult = interrupted(errorInfo, fdv1Fallback);
3331
3418
  }
3332
3419
  else {
3333
3420
  // Non-actionable errors (UNKNOWN_EVENT) are logged but don't stop processing
@@ -3343,19 +3430,18 @@ function processEvents(events, oneShot, fdv1Fallback, environmentId, logger) {
3343
3430
  // Events didn't produce a result
3344
3431
  const errorInfo = errorInfoFromUnknown('Unexpected end of polling response');
3345
3432
  logger?.error('Unexpected end of polling response');
3346
- return oneShot ? terminalError(errorInfo, fdv1Fallback) : interrupted(errorInfo, fdv1Fallback);
3433
+ return interrupted(errorInfo, fdv1Fallback);
3347
3434
  }
3348
3435
  /**
3349
3436
  * Performs a single FDv2 poll request, processes the protocol response, and
3350
3437
  * returns an {@link FDv2SourceResult}.
3351
3438
  *
3352
- * The `oneShot` parameter controls error handling: when true (initializer),
3353
- * all errors are terminal; when false (synchronizer), recoverable errors
3354
- * produce interrupted results.
3439
+ * Recoverable errors produce interrupted results; unrecoverable HTTP errors
3440
+ * produce terminal errors.
3355
3441
  *
3356
3442
  * @internal
3357
3443
  */
3358
- async function poll(requestor, basis, oneShot, logger) {
3444
+ async function poll(requestor, basis, logger) {
3359
3445
  let fdv1Fallback = false;
3360
3446
  let environmentId;
3361
3447
  try {
@@ -3366,7 +3452,6 @@ async function poll(requestor, basis, oneShot, logger) {
3366
3452
  // (Spec Requirement 10.1.2)
3367
3453
  if (response.status === 304) {
3368
3454
  const nonePayload = {
3369
- id: '',
3370
3455
  version: 0,
3371
3456
  type: 'none',
3372
3457
  updates: [],
@@ -3377,9 +3462,6 @@ async function poll(requestor, basis, oneShot, logger) {
3377
3462
  if (response.status < 200 || response.status >= 300) {
3378
3463
  const errorInfo = errorInfoFromHttpError(response.status);
3379
3464
  logger?.error(`Polling request failed with HTTP error: ${response.status}`);
3380
- if (oneShot) {
3381
- return terminalError(errorInfo, fdv1Fallback);
3382
- }
3383
3465
  const recoverable = response.status <= 0 || jsSdkCommon.isHttpRecoverable(response.status);
3384
3466
  return recoverable
3385
3467
  ? interrupted(errorInfo, fdv1Fallback)
@@ -3389,9 +3471,7 @@ async function poll(requestor, basis, oneShot, logger) {
3389
3471
  if (!response.body) {
3390
3472
  const errorInfo = errorInfoFromInvalidData('Empty response body');
3391
3473
  logger?.error('Polling request received empty response body');
3392
- return oneShot
3393
- ? terminalError(errorInfo, fdv1Fallback)
3394
- : interrupted(errorInfo, fdv1Fallback);
3474
+ return interrupted(errorInfo, fdv1Fallback);
3395
3475
  }
3396
3476
  let parsed;
3397
3477
  try {
@@ -3400,34 +3480,36 @@ async function poll(requestor, basis, oneShot, logger) {
3400
3480
  catch {
3401
3481
  const errorInfo = errorInfoFromInvalidData('Malformed JSON data in polling response');
3402
3482
  logger?.error('Polling request received malformed data');
3403
- return oneShot
3404
- ? terminalError(errorInfo, fdv1Fallback)
3405
- : interrupted(errorInfo, fdv1Fallback);
3483
+ return interrupted(errorInfo, fdv1Fallback);
3406
3484
  }
3407
3485
  if (!Array.isArray(parsed.events)) {
3408
3486
  const errorInfo = errorInfoFromInvalidData('Invalid polling response: missing or invalid events array');
3409
3487
  logger?.error('Polling response does not contain a valid events array');
3410
- return oneShot
3411
- ? terminalError(errorInfo, fdv1Fallback)
3412
- : interrupted(errorInfo, fdv1Fallback);
3488
+ return interrupted(errorInfo, fdv1Fallback);
3413
3489
  }
3414
- return processEvents(parsed.events, oneShot, fdv1Fallback, environmentId, logger);
3490
+ return processEvents(parsed.events, fdv1Fallback, environmentId, logger);
3415
3491
  }
3416
3492
  catch (err) {
3417
3493
  // Network or other I/O error from the fetch itself
3418
3494
  const message = err?.message ?? String(err);
3419
3495
  logger?.error(`Polling request failed with network error: ${message}`);
3420
3496
  const errorInfo = errorInfoFromNetworkError(message);
3421
- return oneShot ? terminalError(errorInfo, fdv1Fallback) : interrupted(errorInfo, fdv1Fallback);
3497
+ return interrupted(errorInfo, fdv1Fallback);
3422
3498
  }
3423
3499
  }
3424
3500
 
3501
+ const SHUTDOWN = Symbol('shutdown');
3425
3502
  /**
3426
- * Creates a one-shot polling initializer that performs a single FDv2 poll
3427
- * request and returns the result.
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.
3428
3506
  *
3429
- * All errors are treated as terminal (oneShot=true). If `close()` is called
3430
- * before the poll completes, the result will be a shutdown status.
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.
3431
3513
  *
3432
3514
  * @internal
3433
3515
  */
@@ -3438,12 +3520,40 @@ function createPollingInitializer(requestor, logger, selectorGetter) {
3438
3520
  });
3439
3521
  return {
3440
3522
  async run() {
3441
- const pollResult = poll(requestor, selectorGetter(), true, logger);
3442
- // Race the poll against the shutdown signal
3443
- return Promise.race([shutdownPromise, pollResult]);
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);
3444
3554
  },
3445
3555
  close() {
3446
- shutdownResolve?.(shutdown());
3556
+ shutdownResolve?.(SHUTDOWN);
3447
3557
  shutdownResolve = undefined;
3448
3558
  },
3449
3559
  };
@@ -3506,7 +3616,7 @@ function createPollingSynchronizer(requestor, logger, selectorGetter, pollInterv
3506
3616
  }
3507
3617
  const startTime = Date.now();
3508
3618
  try {
3509
- const result = await poll(requestor, selectorGetter(), false, logger);
3619
+ const result = await poll(requestor, selectorGetter(), logger);
3510
3620
  if (stopped) {
3511
3621
  return;
3512
3622
  }
@@ -3583,6 +3693,100 @@ function createSynchronizerSlot(factory, options) {
3583
3693
  const state = options?.initialState ?? (isFDv1Fallback ? 'blocked' : 'available');
3584
3694
  return { factory, isFDv1Fallback, state };
3585
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
+ }
3586
3790
 
3587
3791
  /**
3588
3792
  * FDv2 event names to listen for on the EventSource. This must stay in sync
@@ -3746,6 +3950,7 @@ function createStreamingBase(config) {
3746
3950
  initialRetryDelayMillis: config.initialRetryDelayMillis,
3747
3951
  readTimeoutMillis: 5 * 60 * 1000,
3748
3952
  retryResetIntervalMillis: 60 * 1000,
3953
+ urlBuilder: buildStreamUri,
3749
3954
  });
3750
3955
  eventSource = es;
3751
3956
  attachFDv2Listeners(es);
@@ -3877,7 +4082,7 @@ function createStreamingSynchronizer(base) {
3877
4082
 
3878
4083
  function createPingHandler(requestor, selectorGetter, logger) {
3879
4084
  return {
3880
- handlePing: () => poll(requestor, selectorGetter(), false, logger),
4085
+ handlePing: () => poll(requestor, selectorGetter(), logger),
3881
4086
  };
3882
4087
  }
3883
4088
  /**
@@ -3968,6 +4173,930 @@ function createDefaultSourceFactoryProvider() {
3968
4173
  };
3969
4174
  }
3970
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
+
3971
5100
  exports.platform = jsSdkCommon__namespace;
3972
5101
  exports.BROWSER_DATA_SYSTEM_DEFAULTS = BROWSER_DATA_SYSTEM_DEFAULTS;
3973
5102
  exports.BROWSER_TRANSITION_TABLE = BROWSER_TRANSITION_TABLE;
@@ -3982,12 +5111,15 @@ exports.MODE_TABLE = MODE_TABLE;
3982
5111
  exports.browserFdv1Endpoints = browserFdv1Endpoints;
3983
5112
  exports.createDataSourceStatusManager = createDataSourceStatusManager;
3984
5113
  exports.createDefaultSourceFactoryProvider = createDefaultSourceFactoryProvider;
5114
+ exports.createFDv2DataManagerBase = createFDv2DataManagerBase;
5115
+ exports.createStateDebounceManager = createStateDebounceManager;
3985
5116
  exports.dataSystemValidators = dataSystemValidators;
3986
5117
  exports.fdv2Endpoints = fdv2Endpoints;
3987
5118
  exports.makeRequestor = makeRequestor;
3988
5119
  exports.mobileFdv1Endpoints = mobileFdv1Endpoints;
3989
5120
  exports.readFlagsFromBootstrap = readFlagsFromBootstrap;
3990
5121
  exports.resolveConnectionMode = resolveConnectionMode;
5122
+ exports.resolveForegroundMode = resolveForegroundMode;
3991
5123
  exports.safeRegisterDebugOverridePlugins = safeRegisterDebugOverridePlugins;
3992
5124
  exports.validateOptions = validateOptions;
3993
5125
  Object.keys(jsSdkCommon).forEach(function (k) {