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