@firebase/remote-config 0.6.6 → 0.7.0-canary.cb3bdd812

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.
@@ -1,11 +1,11 @@
1
1
  import { _getProvider, getApp, _registerComponent, registerVersion, SDK_VERSION } from '@firebase/app';
2
- import { ErrorFactory, FirebaseError, getModularInstance, deepEqual, calculateBackoffMillis, isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util';
2
+ import { ErrorFactory, FirebaseError, getModularInstance, deepEqual, calculateBackoffMillis, assert, isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util';
3
3
  import { Component } from '@firebase/component';
4
4
  import { LogLevel, Logger } from '@firebase/logger';
5
5
  import '@firebase/installations';
6
6
 
7
7
  const name = "@firebase/remote-config";
8
- const version = "0.6.6";
8
+ const version = "0.7.0-canary.cb3bdd812";
9
9
 
10
10
  /**
11
11
  * @license
@@ -101,7 +101,11 @@ const ERROR_DESCRIPTION_MAP = {
101
101
  ' Original error: {$originalErrorMessage}.',
102
102
  ["fetch-status" /* ErrorCode.FETCH_STATUS */]: 'Fetch server returned an HTTP error status. HTTP status: {$httpStatus}.',
103
103
  ["indexed-db-unavailable" /* ErrorCode.INDEXED_DB_UNAVAILABLE */]: 'Indexed DB is not supported by current browser',
104
- ["custom-signal-max-allowed-signals" /* ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS */]: 'Setting more than {$maxSignals} custom signals is not supported.'
104
+ ["custom-signal-max-allowed-signals" /* ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS */]: 'Setting more than {$maxSignals} custom signals is not supported.',
105
+ ["stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */]: 'The stream was not able to connect to the backend: {$originalErrorMessage}.',
106
+ ["realtime-unavailable" /* ErrorCode.CONFIG_UPDATE_UNAVAILABLE */]: 'The Realtime service is unavailable: {$originalErrorMessage}',
107
+ ["update-message-invalid" /* ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID */]: 'The stream invalidation message was unparsable: {$originalErrorMessage}',
108
+ ["update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */]: 'Unable to fetch the latest config: {$originalErrorMessage}'
105
109
  };
106
110
  const ERROR_FACTORY = new ErrorFactory('remoteconfig' /* service */, 'Remote Config' /* service name */, ERROR_DESCRIPTION_MAP);
107
111
  // Note how this is like typeof/instanceof, but for ErrorCode.
@@ -201,6 +205,7 @@ function getRemoteConfig(app = getApp(), options = {}) {
201
205
  rc._initializePromise = Promise.all([
202
206
  rc._storage.setLastSuccessfulFetchResponse(options.initialFetchResponse),
203
207
  rc._storage.setActiveConfigEtag(options.initialFetchResponse?.eTag || ''),
208
+ rc._storage.setActiveConfigTemplateVersion(options.initialFetchResponse.templateVersion || 0),
204
209
  rc._storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()),
205
210
  rc._storageCache.setLastFetchStatus('success'),
206
211
  rc._storageCache.setActiveConfig(options.initialFetchResponse?.config || {})
@@ -228,6 +233,7 @@ async function activate(remoteConfig) {
228
233
  if (!lastSuccessfulFetchResponse ||
229
234
  !lastSuccessfulFetchResponse.config ||
230
235
  !lastSuccessfulFetchResponse.eTag ||
236
+ !lastSuccessfulFetchResponse.templateVersion ||
231
237
  lastSuccessfulFetchResponse.eTag === activeConfigEtag) {
232
238
  // Either there is no successful fetched config, or is the same as current active
233
239
  // config.
@@ -235,7 +241,8 @@ async function activate(remoteConfig) {
235
241
  }
236
242
  await Promise.all([
237
243
  rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
238
- rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag)
244
+ rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag),
245
+ rc._storage.setActiveConfigTemplateVersion(lastSuccessfulFetchResponse.templateVersion)
239
246
  ]);
240
247
  return true;
241
248
  }
@@ -445,6 +452,29 @@ async function setCustomSignals(remoteConfig, customSignals) {
445
452
  rc._logger.error(`Error encountered while setting custom signals: ${error}`);
446
453
  }
447
454
  }
455
+ // TODO: Add public document for the Remote Config Realtime API guide on the Web Platform.
456
+ /**
457
+ * Starts listening for real-time config updates from the Remote Config backend and automatically
458
+ * fetches updates from the Remote Config backend when they are available.
459
+ *
460
+ * @remarks
461
+ * If a connection to the Remote Config backend is not already open, calling this method will
462
+ * open it. Multiple listeners can be added by calling this method again, but subsequent calls
463
+ * re-use the same connection to the backend.
464
+ *
465
+ * @param remoteConfig - The {@link RemoteConfig} instance.
466
+ * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates.
467
+ * @returns An {@link Unsubscribe} function to remove the listener.
468
+ *
469
+ * @public
470
+ */
471
+ function onConfigUpdate(remoteConfig, observer) {
472
+ const rc = getModularInstance(remoteConfig);
473
+ rc._realtimeHandler.addObserver(observer);
474
+ return () => {
475
+ rc._realtimeHandler.removeObserver(observer);
476
+ };
477
+ }
448
478
 
449
479
  /**
450
480
  * @license
@@ -618,6 +648,8 @@ class RestClient {
618
648
  // Deviates from pure decorator by not passing max-age header since we don't currently have
619
649
  // service behavior using that header.
620
650
  'If-None-Match': request.eTag || '*'
651
+ // TODO: Add this header once CORS error is fixed internally.
652
+ //'X-Firebase-RC-Fetch-Type': `${fetchType}/${fetchAttempt}`
621
653
  };
622
654
  const requestBody = {
623
655
  /* eslint-disable camelcase */
@@ -664,6 +696,7 @@ class RestClient {
664
696
  const responseEtag = response.headers.get('ETag') || undefined;
665
697
  let config;
666
698
  let state;
699
+ let templateVersion;
667
700
  // JSON parsing throws SyntaxError if the response body isn't a JSON string.
668
701
  // Requesting application/json and checking for a 200 ensures there's JSON data.
669
702
  if (response.status === 200) {
@@ -678,6 +711,7 @@ class RestClient {
678
711
  }
679
712
  config = responseBody['entries'];
680
713
  state = responseBody['state'];
714
+ templateVersion = responseBody['templateVersion'];
681
715
  }
682
716
  // Normalizes based on legacy state.
683
717
  if (state === 'INSTANCE_STATE_UNSPECIFIED') {
@@ -699,7 +733,7 @@ class RestClient {
699
733
  httpStatus: status
700
734
  });
701
735
  }
702
- return { status, eTag: responseEtag, config };
736
+ return { status, eTag: responseEtag, config, templateVersion };
703
737
  }
704
738
  }
705
739
 
@@ -861,12 +895,17 @@ class RemoteConfig {
861
895
  /**
862
896
  * @internal
863
897
  */
864
- _logger) {
898
+ _logger,
899
+ /**
900
+ * @internal
901
+ */
902
+ _realtimeHandler) {
865
903
  this.app = app;
866
904
  this._client = _client;
867
905
  this._storageCache = _storageCache;
868
906
  this._storage = _storage;
869
907
  this._logger = _logger;
908
+ this._realtimeHandler = _realtimeHandler;
870
909
  /**
871
910
  * Tracks completion of initialization promise.
872
911
  * @internal
@@ -999,6 +1038,18 @@ class Storage {
999
1038
  getCustomSignals() {
1000
1039
  return this.get('custom_signals');
1001
1040
  }
1041
+ getRealtimeBackoffMetadata() {
1042
+ return this.get('realtime_backoff_metadata');
1043
+ }
1044
+ setRealtimeBackoffMetadata(realtimeMetadata) {
1045
+ return this.set('realtime_backoff_metadata', realtimeMetadata);
1046
+ }
1047
+ getActiveConfigTemplateVersion() {
1048
+ return this.get('last_known_template_version');
1049
+ }
1050
+ setActiveConfigTemplateVersion(version) {
1051
+ return this.set('last_known_template_version', version);
1052
+ }
1002
1053
  }
1003
1054
  class IndexedDbStorage extends Storage {
1004
1055
  /**
@@ -1259,6 +1310,683 @@ class StorageCache {
1259
1310
  }
1260
1311
  }
1261
1312
 
1313
+ /**
1314
+ * @license
1315
+ * Copyright 2025 Google LLC
1316
+ *
1317
+ * Licensed under the Apache License, Version 2.0 (the "License");
1318
+ * you may not use this file except in compliance with the License.
1319
+ * You may obtain a copy of the License at
1320
+ *
1321
+ * http://www.apache.org/licenses/LICENSE-2.0
1322
+ *
1323
+ * Unless required by applicable law or agreed to in writing, software
1324
+ * distributed under the License is distributed on an "AS IS" BASIS,
1325
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1326
+ * See the License for the specific language governing permissions and
1327
+ * limitations under the License.
1328
+ */
1329
+ // TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config.
1330
+ /**
1331
+ * Base class to be used if you want to emit events. Call the constructor with
1332
+ * the set of allowed event names.
1333
+ */
1334
+ class EventEmitter {
1335
+ constructor(allowedEvents_) {
1336
+ this.allowedEvents_ = allowedEvents_;
1337
+ this.listeners_ = {};
1338
+ assert(Array.isArray(allowedEvents_) && allowedEvents_.length > 0, 'Requires a non-empty array');
1339
+ }
1340
+ /**
1341
+ * To be called by derived classes to trigger events.
1342
+ */
1343
+ trigger(eventType, ...varArgs) {
1344
+ if (Array.isArray(this.listeners_[eventType])) {
1345
+ // Clone the list, since callbacks could add/remove listeners.
1346
+ const listeners = [...this.listeners_[eventType]];
1347
+ for (let i = 0; i < listeners.length; i++) {
1348
+ listeners[i].callback.apply(listeners[i].context, varArgs);
1349
+ }
1350
+ }
1351
+ }
1352
+ on(eventType, callback, context) {
1353
+ this.validateEventType_(eventType);
1354
+ this.listeners_[eventType] = this.listeners_[eventType] || [];
1355
+ this.listeners_[eventType].push({ callback, context });
1356
+ const eventData = this.getInitialEvent(eventType);
1357
+ if (eventData) {
1358
+ //@ts-ignore
1359
+ callback.apply(context, eventData);
1360
+ }
1361
+ }
1362
+ off(eventType, callback, context) {
1363
+ this.validateEventType_(eventType);
1364
+ const listeners = this.listeners_[eventType] || [];
1365
+ for (let i = 0; i < listeners.length; i++) {
1366
+ if (listeners[i].callback === callback &&
1367
+ (!context || context === listeners[i].context)) {
1368
+ listeners.splice(i, 1);
1369
+ return;
1370
+ }
1371
+ }
1372
+ }
1373
+ validateEventType_(eventType) {
1374
+ assert(this.allowedEvents_.find(et => {
1375
+ return et === eventType;
1376
+ }), 'Unknown event: ' + eventType);
1377
+ }
1378
+ }
1379
+
1380
+ /**
1381
+ * @license
1382
+ * Copyright 2025 Google LLC
1383
+ *
1384
+ * Licensed under the Apache License, Version 2.0 (the "License");
1385
+ * you may not use this file except in compliance with the License.
1386
+ * You may obtain a copy of the License at
1387
+ *
1388
+ * http://www.apache.org/licenses/LICENSE-2.0
1389
+ *
1390
+ * Unless required by applicable law or agreed to in writing, software
1391
+ * distributed under the License is distributed on an "AS IS" BASIS,
1392
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1393
+ * See the License for the specific language governing permissions and
1394
+ * limitations under the License.
1395
+ */
1396
+ // TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config.
1397
+ class VisibilityMonitor extends EventEmitter {
1398
+ static getInstance() {
1399
+ return new VisibilityMonitor();
1400
+ }
1401
+ constructor() {
1402
+ super(['visible']);
1403
+ let hidden;
1404
+ let visibilityChange;
1405
+ if (typeof document !== 'undefined' &&
1406
+ typeof document.addEventListener !== 'undefined') {
1407
+ if (typeof document['hidden'] !== 'undefined') {
1408
+ // Opera 12.10 and Firefox 18 and later support
1409
+ visibilityChange = 'visibilitychange';
1410
+ hidden = 'hidden';
1411
+ } // @ts-ignore
1412
+ else if (typeof document['mozHidden'] !== 'undefined') {
1413
+ visibilityChange = 'mozvisibilitychange';
1414
+ hidden = 'mozHidden';
1415
+ } // @ts-ignore
1416
+ else if (typeof document['msHidden'] !== 'undefined') {
1417
+ visibilityChange = 'msvisibilitychange';
1418
+ hidden = 'msHidden';
1419
+ } // @ts-ignore
1420
+ else if (typeof document['webkitHidden'] !== 'undefined') {
1421
+ visibilityChange = 'webkitvisibilitychange';
1422
+ hidden = 'webkitHidden';
1423
+ }
1424
+ }
1425
+ // Initially, we always assume we are visible. This ensures that in browsers
1426
+ // without page visibility support or in cases where we are never visible
1427
+ // (e.g. chrome extension), we act as if we are visible, i.e. don't delay
1428
+ // reconnects
1429
+ this.visible_ = true;
1430
+ // @ts-ignore
1431
+ if (visibilityChange) {
1432
+ document.addEventListener(visibilityChange, () => {
1433
+ // @ts-ignore
1434
+ const visible = !document[hidden];
1435
+ if (visible !== this.visible_) {
1436
+ this.visible_ = visible;
1437
+ this.trigger('visible', visible);
1438
+ }
1439
+ }, false);
1440
+ }
1441
+ }
1442
+ getInitialEvent(eventType) {
1443
+ assert(eventType === 'visible', 'Unknown event type: ' + eventType);
1444
+ return [this.visible_];
1445
+ }
1446
+ }
1447
+
1448
+ /**
1449
+ * @license
1450
+ * Copyright 2025 Google LLC
1451
+ *
1452
+ * Licensed under the Apache License, Version 2.0 (the "License");
1453
+ * you may not use this file except in compliance with the License.
1454
+ * You may obtain a copy of the License at
1455
+ *
1456
+ * http://www.apache.org/licenses/LICENSE-2.0
1457
+ *
1458
+ * Unless required by applicable law or agreed to in writing, software
1459
+ * distributed under the License is distributed on an "AS IS" BASIS,
1460
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
1461
+ * See the License for the specific language governing permissions and
1462
+ * limitations under the License.
1463
+ */
1464
+ const API_KEY_HEADER = 'X-Goog-Api-Key';
1465
+ const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth';
1466
+ const ORIGINAL_RETRIES = 8;
1467
+ const MAXIMUM_FETCH_ATTEMPTS = 3;
1468
+ const NO_BACKOFF_TIME_IN_MILLIS = -1;
1469
+ const NO_FAILED_REALTIME_STREAMS = 0;
1470
+ const REALTIME_DISABLED_KEY = 'featureDisabled';
1471
+ const REALTIME_RETRY_INTERVAL = 'retryIntervalSeconds';
1472
+ const TEMPLATE_VERSION_KEY = 'latestTemplateVersionNumber';
1473
+ class RealtimeHandler {
1474
+ constructor(firebaseInstallations, storage, sdkVersion, namespace, projectId, apiKey, appId, logger, storageCache, cachingClient) {
1475
+ this.firebaseInstallations = firebaseInstallations;
1476
+ this.storage = storage;
1477
+ this.sdkVersion = sdkVersion;
1478
+ this.namespace = namespace;
1479
+ this.projectId = projectId;
1480
+ this.apiKey = apiKey;
1481
+ this.appId = appId;
1482
+ this.logger = logger;
1483
+ this.storageCache = storageCache;
1484
+ this.cachingClient = cachingClient;
1485
+ this.observers = new Set();
1486
+ this.isConnectionActive = false;
1487
+ this.isRealtimeDisabled = false;
1488
+ this.httpRetriesRemaining = ORIGINAL_RETRIES;
1489
+ this.isInBackground = false;
1490
+ this.decoder = new TextDecoder('utf-8');
1491
+ this.isClosingConnection = false;
1492
+ this.propagateError = (e) => this.observers.forEach(o => o.error?.(e));
1493
+ /**
1494
+ * HTTP status code that the Realtime client should retry on.
1495
+ */
1496
+ this.isStatusCodeRetryable = (statusCode) => {
1497
+ const retryableStatusCodes = [
1498
+ 408, // Request Timeout
1499
+ 429, // Too Many Requests
1500
+ 502, // Bad Gateway
1501
+ 503, // Service Unavailable
1502
+ 504 // Gateway Timeout
1503
+ ];
1504
+ return !statusCode || retryableStatusCodes.includes(statusCode);
1505
+ };
1506
+ void this.setRetriesRemaining();
1507
+ void VisibilityMonitor.getInstance().on('visible', this.onVisibilityChange, this);
1508
+ }
1509
+ async setRetriesRemaining() {
1510
+ // Retrieve number of remaining retries from last session. The minimum retry count being one.
1511
+ const metadata = await this.storage.getRealtimeBackoffMetadata();
1512
+ const numFailedStreams = metadata?.numFailedStreams || 0;
1513
+ this.httpRetriesRemaining = Math.max(ORIGINAL_RETRIES - numFailedStreams, 1);
1514
+ }
1515
+ /**
1516
+ * Increment the number of failed stream attempts, increase the backoff duration, set the backoff
1517
+ * end time to "backoff duration" after `lastFailedStreamTime` and persist the new
1518
+ * values to storage metadata.
1519
+ */
1520
+ async updateBackoffMetadataWithLastFailedStreamConnectionTime(lastFailedStreamTime) {
1521
+ const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams ||
1522
+ 0) + 1;
1523
+ const backoffMillis = calculateBackoffMillis(numFailedStreams, 60000, 2);
1524
+ await this.storage.setRealtimeBackoffMetadata({
1525
+ backoffEndTimeMillis: new Date(lastFailedStreamTime.getTime() + backoffMillis),
1526
+ numFailedStreams
1527
+ });
1528
+ }
1529
+ /**
1530
+ * Increase the backoff duration with a new end time based on Retry Interval.
1531
+ */
1532
+ async updateBackoffMetadataWithRetryInterval(retryIntervalSeconds) {
1533
+ const currentTime = Date.now();
1534
+ const backoffDurationInMillis = retryIntervalSeconds * 1000;
1535
+ const backoffEndTime = new Date(currentTime + backoffDurationInMillis);
1536
+ const numFailedStreams = 0;
1537
+ await this.storage.setRealtimeBackoffMetadata({
1538
+ backoffEndTimeMillis: backoffEndTime,
1539
+ numFailedStreams
1540
+ });
1541
+ await this.retryHttpConnectionWhenBackoffEnds();
1542
+ }
1543
+ /**
1544
+ * Closes the realtime HTTP connection.
1545
+ * Note: This method is designed to be called only once at a time.
1546
+ * If a call is already in progress, subsequent calls will be ignored.
1547
+ */
1548
+ async closeRealtimeHttpConnection() {
1549
+ if (this.isClosingConnection) {
1550
+ return;
1551
+ }
1552
+ this.isClosingConnection = true;
1553
+ try {
1554
+ if (this.reader) {
1555
+ await this.reader.cancel();
1556
+ }
1557
+ }
1558
+ catch (e) {
1559
+ // The network connection was lost, so cancel() failed.
1560
+ // This is expected in a disconnected state, so we can safely ignore the error.
1561
+ this.logger.debug('Failed to cancel the reader, connection was lost.');
1562
+ }
1563
+ finally {
1564
+ this.reader = undefined;
1565
+ }
1566
+ if (this.controller) {
1567
+ await this.controller.abort();
1568
+ this.controller = undefined;
1569
+ }
1570
+ this.isClosingConnection = false;
1571
+ }
1572
+ async resetRealtimeBackoff() {
1573
+ await this.storage.setRealtimeBackoffMetadata({
1574
+ backoffEndTimeMillis: new Date(-1),
1575
+ numFailedStreams: 0
1576
+ });
1577
+ }
1578
+ resetRetryCount() {
1579
+ this.httpRetriesRemaining = ORIGINAL_RETRIES;
1580
+ }
1581
+ /**
1582
+ * Assembles the request headers and body and executes the fetch request to
1583
+ * establish the real-time streaming connection. This is the "worker" method
1584
+ * that performs the actual network communication.
1585
+ */
1586
+ async establishRealtimeConnection(url, installationId, installationTokenResult, signal) {
1587
+ const eTagValue = await this.storage.getActiveConfigEtag();
1588
+ const lastKnownVersionNumber = await this.storage.getActiveConfigTemplateVersion();
1589
+ const headers = {
1590
+ [API_KEY_HEADER]: this.apiKey,
1591
+ [INSTALLATIONS_AUTH_TOKEN_HEADER]: installationTokenResult,
1592
+ 'Content-Type': 'application/json',
1593
+ 'Accept': 'application/json',
1594
+ 'If-None-Match': eTagValue || '*',
1595
+ 'Content-Encoding': 'gzip'
1596
+ };
1597
+ const requestBody = {
1598
+ project: this.projectId,
1599
+ namespace: this.namespace,
1600
+ lastKnownVersionNumber,
1601
+ appId: this.appId,
1602
+ sdkVersion: this.sdkVersion,
1603
+ appInstanceId: installationId
1604
+ };
1605
+ const response = await fetch(url, {
1606
+ method: 'POST',
1607
+ headers,
1608
+ body: JSON.stringify(requestBody),
1609
+ signal
1610
+ });
1611
+ return response;
1612
+ }
1613
+ getRealtimeUrl() {
1614
+ const urlBase = window.FIREBASE_REMOTE_CONFIG_URL_BASE ||
1615
+ 'https://firebaseremoteconfigrealtime.googleapis.com';
1616
+ const urlString = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:streamFetchInvalidations?key=${this.apiKey}`;
1617
+ return new URL(urlString);
1618
+ }
1619
+ async createRealtimeConnection() {
1620
+ const [installationId, installationTokenResult] = await Promise.all([
1621
+ this.firebaseInstallations.getId(),
1622
+ this.firebaseInstallations.getToken(false)
1623
+ ]);
1624
+ this.controller = new AbortController();
1625
+ const url = this.getRealtimeUrl();
1626
+ const realtimeConnection = await this.establishRealtimeConnection(url, installationId, installationTokenResult, this.controller.signal);
1627
+ return realtimeConnection;
1628
+ }
1629
+ /**
1630
+ * Retries HTTP stream connection asyncly in random time intervals.
1631
+ */
1632
+ async retryHttpConnectionWhenBackoffEnds() {
1633
+ let backoffMetadata = await this.storage.getRealtimeBackoffMetadata();
1634
+ if (!backoffMetadata) {
1635
+ backoffMetadata = {
1636
+ backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS),
1637
+ numFailedStreams: NO_FAILED_REALTIME_STREAMS
1638
+ };
1639
+ }
1640
+ const backoffEndTime = new Date(backoffMetadata.backoffEndTimeMillis).getTime();
1641
+ const currentTime = Date.now();
1642
+ const retryMillis = Math.max(0, backoffEndTime - currentTime);
1643
+ await this.makeRealtimeHttpConnection(retryMillis);
1644
+ }
1645
+ setIsHttpConnectionRunning(connectionRunning) {
1646
+ this.isConnectionActive = connectionRunning;
1647
+ }
1648
+ /**
1649
+ * Combines the check and set operations to prevent multiple asynchronous
1650
+ * calls from redundantly starting an HTTP connection. This ensures that
1651
+ * only one attempt is made at a time.
1652
+ */
1653
+ checkAndSetHttpConnectionFlagIfNotRunning() {
1654
+ const canMakeConnection = this.canEstablishStreamConnection();
1655
+ if (canMakeConnection) {
1656
+ this.setIsHttpConnectionRunning(true);
1657
+ }
1658
+ return canMakeConnection;
1659
+ }
1660
+ fetchResponseIsUpToDate(fetchResponse, lastKnownVersion) {
1661
+ // If there is a config, make sure its version is >= the last known version.
1662
+ if (fetchResponse.config != null && fetchResponse.templateVersion) {
1663
+ return fetchResponse.templateVersion >= lastKnownVersion;
1664
+ }
1665
+ // If there isn't a config, return true if the fetch was successful and backend had no update.
1666
+ // Else, it returned an out of date config.
1667
+ return this.storageCache.getLastFetchStatus() === 'success';
1668
+ }
1669
+ parseAndValidateConfigUpdateMessage(message) {
1670
+ const left = message.indexOf('{');
1671
+ const right = message.indexOf('}', left);
1672
+ if (left < 0 || right < 0) {
1673
+ return '';
1674
+ }
1675
+ return left >= right ? '' : message.substring(left, right + 1);
1676
+ }
1677
+ isEventListenersEmpty() {
1678
+ return this.observers.size === 0;
1679
+ }
1680
+ getRandomInt(max) {
1681
+ return Math.floor(Math.random() * max);
1682
+ }
1683
+ executeAllListenerCallbacks(configUpdate) {
1684
+ this.observers.forEach(observer => observer.next(configUpdate));
1685
+ }
1686
+ /**
1687
+ * Compares two configuration objects and returns a set of keys that have changed.
1688
+ * A key is considered changed if it's new, removed, or has a different value.
1689
+ */
1690
+ getChangedParams(newConfig, oldConfig) {
1691
+ const changedKeys = new Set();
1692
+ const newKeys = new Set(Object.keys(newConfig || {}));
1693
+ const oldKeys = new Set(Object.keys(oldConfig || {}));
1694
+ for (const key of newKeys) {
1695
+ if (!oldKeys.has(key) || newConfig[key] !== oldConfig[key]) {
1696
+ changedKeys.add(key);
1697
+ }
1698
+ }
1699
+ for (const key of oldKeys) {
1700
+ if (!newKeys.has(key)) {
1701
+ changedKeys.add(key);
1702
+ }
1703
+ }
1704
+ return changedKeys;
1705
+ }
1706
+ async fetchLatestConfig(remainingAttempts, targetVersion) {
1707
+ const remainingAttemptsAfterFetch = remainingAttempts - 1;
1708
+ const currentAttempt = MAXIMUM_FETCH_ATTEMPTS - remainingAttemptsAfterFetch;
1709
+ const customSignals = this.storageCache.getCustomSignals();
1710
+ if (customSignals) {
1711
+ this.logger.debug(`Fetching config with custom signals: ${JSON.stringify(customSignals)}`);
1712
+ }
1713
+ const abortSignal = new RemoteConfigAbortSignal();
1714
+ try {
1715
+ const fetchRequest = {
1716
+ cacheMaxAgeMillis: 0,
1717
+ signal: abortSignal,
1718
+ customSignals,
1719
+ fetchType: 'REALTIME',
1720
+ fetchAttempt: currentAttempt
1721
+ };
1722
+ const fetchResponse = await this.cachingClient.fetch(fetchRequest);
1723
+ let activatedConfigs = await this.storage.getActiveConfig();
1724
+ if (!this.fetchResponseIsUpToDate(fetchResponse, targetVersion)) {
1725
+ this.logger.debug("Fetched template version is the same as SDK's current version." +
1726
+ ' Retrying fetch.');
1727
+ // Continue fetching until template version number is greater than current.
1728
+ await this.autoFetch(remainingAttemptsAfterFetch, targetVersion);
1729
+ return;
1730
+ }
1731
+ if (fetchResponse.config == null) {
1732
+ this.logger.debug('The fetch succeeded, but the backend had no updates.');
1733
+ return;
1734
+ }
1735
+ if (activatedConfigs == null) {
1736
+ activatedConfigs = {};
1737
+ }
1738
+ const updatedKeys = this.getChangedParams(fetchResponse.config, activatedConfigs);
1739
+ if (updatedKeys.size === 0) {
1740
+ this.logger.debug('Config was fetched, but no params changed.');
1741
+ return;
1742
+ }
1743
+ const configUpdate = {
1744
+ getUpdatedKeys() {
1745
+ return new Set(updatedKeys);
1746
+ }
1747
+ };
1748
+ this.executeAllListenerCallbacks(configUpdate);
1749
+ }
1750
+ catch (e) {
1751
+ const errorMessage = e instanceof Error ? e.message : String(e);
1752
+ const error = ERROR_FACTORY.create("update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */, {
1753
+ originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}`
1754
+ });
1755
+ this.propagateError(error);
1756
+ }
1757
+ }
1758
+ async autoFetch(remainingAttempts, targetVersion) {
1759
+ if (remainingAttempts === 0) {
1760
+ const error = ERROR_FACTORY.create("update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */, {
1761
+ originalErrorMessage: 'Unable to fetch the latest version of the template.'
1762
+ });
1763
+ this.propagateError(error);
1764
+ return;
1765
+ }
1766
+ const timeTillFetchSeconds = this.getRandomInt(4);
1767
+ const timeTillFetchInMiliseconds = timeTillFetchSeconds * 1000;
1768
+ await new Promise(resolve => setTimeout(resolve, timeTillFetchInMiliseconds));
1769
+ await this.fetchLatestConfig(remainingAttempts, targetVersion);
1770
+ }
1771
+ /**
1772
+ * Processes a stream of real-time messages for configuration updates.
1773
+ * This method reassembles fragmented messages, validates and parses the JSON,
1774
+ * and automatically fetches a new config if a newer template version is available.
1775
+ * It also handles server-specified retry intervals and propagates errors for
1776
+ * invalid messages or when real-time updates are disabled.
1777
+ */
1778
+ async handleNotifications(reader) {
1779
+ let partialConfigUpdateMessage;
1780
+ let currentConfigUpdateMessage = '';
1781
+ while (true) {
1782
+ const { done, value } = await reader.read();
1783
+ if (done) {
1784
+ break;
1785
+ }
1786
+ partialConfigUpdateMessage = this.decoder.decode(value, { stream: true });
1787
+ currentConfigUpdateMessage += partialConfigUpdateMessage;
1788
+ if (partialConfigUpdateMessage.includes('}')) {
1789
+ currentConfigUpdateMessage = this.parseAndValidateConfigUpdateMessage(currentConfigUpdateMessage);
1790
+ if (currentConfigUpdateMessage.length === 0) {
1791
+ continue;
1792
+ }
1793
+ try {
1794
+ const jsonObject = JSON.parse(currentConfigUpdateMessage);
1795
+ if (this.isEventListenersEmpty()) {
1796
+ break;
1797
+ }
1798
+ if (REALTIME_DISABLED_KEY in jsonObject &&
1799
+ jsonObject[REALTIME_DISABLED_KEY] === true) {
1800
+ const error = ERROR_FACTORY.create("realtime-unavailable" /* ErrorCode.CONFIG_UPDATE_UNAVAILABLE */, {
1801
+ originalErrorMessage: 'The server is temporarily unavailable. Try again in a few minutes.'
1802
+ });
1803
+ this.propagateError(error);
1804
+ break;
1805
+ }
1806
+ if (TEMPLATE_VERSION_KEY in jsonObject) {
1807
+ const oldTemplateVersion = await this.storage.getActiveConfigTemplateVersion();
1808
+ const targetTemplateVersion = Number(jsonObject[TEMPLATE_VERSION_KEY]);
1809
+ if (oldTemplateVersion &&
1810
+ targetTemplateVersion > oldTemplateVersion) {
1811
+ await this.autoFetch(MAXIMUM_FETCH_ATTEMPTS, targetTemplateVersion);
1812
+ }
1813
+ }
1814
+ // This field in the response indicates that the realtime request should retry after the
1815
+ // specified interval to establish a long-lived connection. This interval extends the
1816
+ // backoff duration without affecting the number of retries, so it will not enter an
1817
+ // exponential backoff state.
1818
+ if (REALTIME_RETRY_INTERVAL in jsonObject) {
1819
+ const retryIntervalSeconds = Number(jsonObject[REALTIME_RETRY_INTERVAL]);
1820
+ await this.updateBackoffMetadataWithRetryInterval(retryIntervalSeconds);
1821
+ }
1822
+ }
1823
+ catch (e) {
1824
+ this.logger.debug('Unable to parse latest config update message.', e);
1825
+ const errorMessage = e instanceof Error ? e.message : String(e);
1826
+ this.propagateError(ERROR_FACTORY.create("update-message-invalid" /* ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID */, {
1827
+ originalErrorMessage: errorMessage
1828
+ }));
1829
+ }
1830
+ currentConfigUpdateMessage = '';
1831
+ }
1832
+ }
1833
+ }
1834
+ async listenForNotifications(reader) {
1835
+ try {
1836
+ await this.handleNotifications(reader);
1837
+ }
1838
+ catch (e) {
1839
+ // If the real-time connection is at an unexpected lifecycle state when the app is
1840
+ // backgrounded, it's expected closing the connection will throw an exception.
1841
+ if (!this.isInBackground) {
1842
+ // Otherwise, the real-time server connection was closed due to a transient issue.
1843
+ this.logger.debug('Real-time connection was closed due to an exception.');
1844
+ }
1845
+ }
1846
+ }
1847
+ /**
1848
+ * Open the real-time connection, begin listening for updates, and auto-fetch when an update is
1849
+ * received.
1850
+ *
1851
+ * If the connection is successful, this method will block on its thread while it reads the
1852
+ * chunk-encoded HTTP body. When the connection closes, it attempts to reestablish the stream.
1853
+ */
1854
+ async prepareAndBeginRealtimeHttpStream() {
1855
+ if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) {
1856
+ return;
1857
+ }
1858
+ let backoffMetadata = await this.storage.getRealtimeBackoffMetadata();
1859
+ if (!backoffMetadata) {
1860
+ backoffMetadata = {
1861
+ backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS),
1862
+ numFailedStreams: NO_FAILED_REALTIME_STREAMS
1863
+ };
1864
+ }
1865
+ const backoffEndTime = backoffMetadata.backoffEndTimeMillis.getTime();
1866
+ if (Date.now() < backoffEndTime) {
1867
+ await this.retryHttpConnectionWhenBackoffEnds();
1868
+ return;
1869
+ }
1870
+ let response;
1871
+ let responseCode;
1872
+ try {
1873
+ response = await this.createRealtimeConnection();
1874
+ responseCode = response.status;
1875
+ if (response.ok && response.body) {
1876
+ this.resetRetryCount();
1877
+ await this.resetRealtimeBackoff();
1878
+ const reader = response.body.getReader();
1879
+ this.reader = reader;
1880
+ // Start listening for realtime notifications.
1881
+ await this.listenForNotifications(reader);
1882
+ }
1883
+ }
1884
+ catch (error) {
1885
+ if (this.isInBackground) {
1886
+ // It's possible the app was backgrounded while the connection was open, which
1887
+ // threw an exception trying to read the response. No real error here, so treat
1888
+ // this as a success, even if we haven't read a 200 response code yet.
1889
+ this.resetRetryCount();
1890
+ }
1891
+ else {
1892
+ //there might have been a transient error so the client will retry the connection.
1893
+ this.logger.debug('Exception connecting to real-time RC backend. Retrying the connection...:', error);
1894
+ }
1895
+ }
1896
+ finally {
1897
+ // Close HTTP connection and associated streams.
1898
+ await this.closeRealtimeHttpConnection();
1899
+ this.setIsHttpConnectionRunning(false);
1900
+ // Update backoff metadata if the connection failed in the foreground.
1901
+ const connectionFailed = !this.isInBackground &&
1902
+ (responseCode === undefined ||
1903
+ this.isStatusCodeRetryable(responseCode));
1904
+ if (connectionFailed) {
1905
+ await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date());
1906
+ }
1907
+ // If responseCode is null then no connection was made to server and the SDK should still retry.
1908
+ if (connectionFailed || response?.ok) {
1909
+ await this.retryHttpConnectionWhenBackoffEnds();
1910
+ }
1911
+ else {
1912
+ const errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`;
1913
+ const firebaseError = ERROR_FACTORY.create("stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */, {
1914
+ originalErrorMessage: errorMessage
1915
+ });
1916
+ this.propagateError(firebaseError);
1917
+ }
1918
+ }
1919
+ }
1920
+ /**
1921
+ * Checks whether connection can be made or not based on some conditions
1922
+ * @returns booelean
1923
+ */
1924
+ canEstablishStreamConnection() {
1925
+ const hasActiveListeners = this.observers.size > 0;
1926
+ const isNotDisabled = !this.isRealtimeDisabled;
1927
+ const isNoConnectionActive = !this.isConnectionActive;
1928
+ const inForeground = !this.isInBackground;
1929
+ return (hasActiveListeners &&
1930
+ isNotDisabled &&
1931
+ isNoConnectionActive &&
1932
+ inForeground);
1933
+ }
1934
+ async makeRealtimeHttpConnection(delayMillis) {
1935
+ if (!this.canEstablishStreamConnection()) {
1936
+ return;
1937
+ }
1938
+ if (this.httpRetriesRemaining > 0) {
1939
+ this.httpRetriesRemaining--;
1940
+ await new Promise(resolve => setTimeout(resolve, delayMillis));
1941
+ void this.prepareAndBeginRealtimeHttpStream();
1942
+ }
1943
+ else if (!this.isInBackground) {
1944
+ const error = ERROR_FACTORY.create("stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */, {
1945
+ originalErrorMessage: 'Unable to connect to the server. Check your connection and try again.'
1946
+ });
1947
+ this.propagateError(error);
1948
+ }
1949
+ }
1950
+ async beginRealtime() {
1951
+ if (this.observers.size > 0) {
1952
+ await this.makeRealtimeHttpConnection(0);
1953
+ }
1954
+ }
1955
+ /**
1956
+ * Adds an observer to the realtime updates.
1957
+ * @param observer The observer to add.
1958
+ */
1959
+ addObserver(observer) {
1960
+ this.observers.add(observer);
1961
+ void this.beginRealtime();
1962
+ }
1963
+ /**
1964
+ * Removes an observer from the realtime updates.
1965
+ * @param observer The observer to remove.
1966
+ */
1967
+ removeObserver(observer) {
1968
+ if (this.observers.has(observer)) {
1969
+ this.observers.delete(observer);
1970
+ }
1971
+ }
1972
+ /**
1973
+ * Handles changes to the application's visibility state, managing the real-time connection.
1974
+ *
1975
+ * When the application is moved to the background, this method closes the existing
1976
+ * real-time connection to save resources. When the application returns to the
1977
+ * foreground, it attempts to re-establish the connection.
1978
+ */
1979
+ async onVisibilityChange(visible) {
1980
+ this.isInBackground = !visible;
1981
+ if (!visible) {
1982
+ await this.closeRealtimeHttpConnection();
1983
+ }
1984
+ else if (visible) {
1985
+ await this.beginRealtime();
1986
+ }
1987
+ }
1988
+ }
1989
+
1262
1990
  /**
1263
1991
  * @license
1264
1992
  * Copyright 2020 Google LLC
@@ -1313,7 +2041,8 @@ function registerRemoteConfig() {
1313
2041
  SDK_VERSION, namespace, projectId, apiKey, appId);
1314
2042
  const retryingClient = new RetryingClient(restClient, storage);
1315
2043
  const cachingClient = new CachingClient(retryingClient, storage, storageCache, logger);
1316
- const remoteConfigInstance = new RemoteConfig(app, cachingClient, storageCache, storage, logger);
2044
+ const realtimeHandler = new RealtimeHandler(installations, storage, SDK_VERSION, namespace, projectId, apiKey, appId, logger, storageCache, cachingClient);
2045
+ const remoteConfigInstance = new RemoteConfig(app, cachingClient, storageCache, storage, logger, realtimeHandler);
1317
2046
  // Starts warming cache.
1318
2047
  // eslint-disable-next-line @typescript-eslint/no-floating-promises
1319
2048
  ensureInitialized(remoteConfigInstance);
@@ -1387,5 +2116,5 @@ async function isSupported() {
1387
2116
  /** register component and version */
1388
2117
  registerRemoteConfig();
1389
2118
 
1390
- export { activate, ensureInitialized, fetchAndActivate, fetchConfig, getAll, getBoolean, getNumber, getRemoteConfig, getString, getValue, isSupported, setCustomSignals, setLogLevel };
2119
+ export { activate, ensureInitialized, fetchAndActivate, fetchConfig, getAll, getBoolean, getNumber, getRemoteConfig, getString, getValue, isSupported, onConfigUpdate, setCustomSignals, setLogLevel };
1391
2120
  //# sourceMappingURL=index.esm.js.map