'use strict'; Object.defineProperty(exports, '__esModule', { value: true }); var app = require('@firebase/app'); var util = require('@firebase/util'); var component = require('@firebase/component'); var logger = require('@firebase/logger'); require('@firebase/installations'); const name = "@firebase/remote-config"; const version = "0.7.0"; /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Shims a minimal AbortSignal. * *

AbortController's AbortSignal conveniently decouples fetch timeout logic from other aspects * of networking, such as retries. Firebase doesn't use AbortController enough to justify a * polyfill recommendation, like we do with the Fetch API, but this minimal shim can easily be * swapped out if/when we do. */ class RemoteConfigAbortSignal { constructor() { this.listeners = []; } addEventListener(listener) { this.listeners.push(listener); } abort() { this.listeners.forEach(listener => listener()); } } /** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const RC_COMPONENT_NAME = 'remote-config'; const RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 100; const RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH = 250; const RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH = 500; /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const ERROR_DESCRIPTION_MAP = { ["already-initialized" /* ErrorCode.ALREADY_INITIALIZED */]: 'Remote Config already initialized', ["registration-window" /* ErrorCode.REGISTRATION_WINDOW */]: 'Undefined window object. This SDK only supports usage in a browser environment.', ["registration-project-id" /* ErrorCode.REGISTRATION_PROJECT_ID */]: 'Undefined project identifier. Check Firebase app initialization.', ["registration-api-key" /* ErrorCode.REGISTRATION_API_KEY */]: 'Undefined API key. Check Firebase app initialization.', ["registration-app-id" /* ErrorCode.REGISTRATION_APP_ID */]: 'Undefined app identifier. Check Firebase app initialization.', ["storage-open" /* ErrorCode.STORAGE_OPEN */]: 'Error thrown when opening storage. Original error: {$originalErrorMessage}.', ["storage-get" /* ErrorCode.STORAGE_GET */]: 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.', ["storage-set" /* ErrorCode.STORAGE_SET */]: 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.', ["storage-delete" /* ErrorCode.STORAGE_DELETE */]: 'Error thrown when deleting from storage. Original error: {$originalErrorMessage}.', ["fetch-client-network" /* ErrorCode.FETCH_NETWORK */]: 'Fetch client failed to connect to a network. Check Internet connection.' + ' Original error: {$originalErrorMessage}.', ["fetch-timeout" /* ErrorCode.FETCH_TIMEOUT */]: 'The config fetch request timed out. ' + ' Configure timeout using "fetchTimeoutMillis" SDK setting.', ["fetch-throttle" /* ErrorCode.FETCH_THROTTLE */]: 'The config fetch request timed out while in an exponential backoff state.' + ' Configure timeout using "fetchTimeoutMillis" SDK setting.' + ' Unix timestamp in milliseconds when fetch request throttling ends: {$throttleEndTimeMillis}.', ["fetch-client-parse" /* ErrorCode.FETCH_PARSE */]: 'Fetch client could not parse response.' + ' Original error: {$originalErrorMessage}.', ["fetch-status" /* ErrorCode.FETCH_STATUS */]: 'Fetch server returned an HTTP error status. HTTP status: {$httpStatus}.', ["indexed-db-unavailable" /* ErrorCode.INDEXED_DB_UNAVAILABLE */]: 'Indexed DB is not supported by current browser', ["custom-signal-max-allowed-signals" /* ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS */]: 'Setting more than {$maxSignals} custom signals is not supported.', ["stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */]: 'The stream was not able to connect to the backend: {$originalErrorMessage}.', ["realtime-unavailable" /* ErrorCode.CONFIG_UPDATE_UNAVAILABLE */]: 'The Realtime service is unavailable: {$originalErrorMessage}', ["update-message-invalid" /* ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID */]: 'The stream invalidation message was unparsable: {$originalErrorMessage}', ["update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */]: 'Unable to fetch the latest config: {$originalErrorMessage}' }; const ERROR_FACTORY = new util.ErrorFactory('remoteconfig' /* service */, 'Remote Config' /* service name */, ERROR_DESCRIPTION_MAP); // Note how this is like typeof/instanceof, but for ErrorCode. function hasErrorCode(e, errorCode) { return e instanceof util.FirebaseError && e.code.indexOf(errorCode) !== -1; } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const DEFAULT_VALUE_FOR_BOOLEAN = false; const DEFAULT_VALUE_FOR_STRING = ''; const DEFAULT_VALUE_FOR_NUMBER = 0; const BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on']; class Value { constructor(_source, _value = DEFAULT_VALUE_FOR_STRING) { this._source = _source; this._value = _value; } asString() { return this._value; } asBoolean() { if (this._source === 'static') { return DEFAULT_VALUE_FOR_BOOLEAN; } return BOOLEAN_TRUTHY_VALUES.indexOf(this._value.toLowerCase()) >= 0; } asNumber() { if (this._source === 'static') { return DEFAULT_VALUE_FOR_NUMBER; } let num = Number(this._value); if (isNaN(num)) { num = DEFAULT_VALUE_FOR_NUMBER; } return num; } getSource() { return this._source; } } /** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * * @param app - The {@link @firebase/app#FirebaseApp} instance. * @param options - Optional. The {@link RemoteConfigOptions} with which to instantiate the * Remote Config instance. * @returns A {@link RemoteConfig} instance. * * @public */ function getRemoteConfig(app$1 = app.getApp(), options = {}) { app$1 = util.getModularInstance(app$1); const rcProvider = app._getProvider(app$1, RC_COMPONENT_NAME); if (rcProvider.isInitialized()) { const initialOptions = rcProvider.getOptions(); if (util.deepEqual(initialOptions, options)) { return rcProvider.getImmediate(); } throw ERROR_FACTORY.create("already-initialized" /* ErrorCode.ALREADY_INITIALIZED */); } rcProvider.initialize({ options }); const rc = rcProvider.getImmediate(); if (options.initialFetchResponse) { // We use these initial writes as the initialization promise since they will hydrate the same // fields that `storageCache.loadFromStorage` would set. rc._initializePromise = Promise.all([ rc._storage.setLastSuccessfulFetchResponse(options.initialFetchResponse), rc._storage.setActiveConfigEtag(options.initialFetchResponse?.eTag || ''), rc._storage.setActiveConfigTemplateVersion(options.initialFetchResponse.templateVersion || 0), rc._storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()), rc._storageCache.setLastFetchStatus('success'), rc._storageCache.setActiveConfig(options.initialFetchResponse?.config || {}) ]).then(); // The `storageCache` methods above set their in-memory fields synchronously, so it's // safe to declare our initialization complete at this point. rc._isInitializationComplete = true; } return rc; } /** * Makes the last fetched config available to the getters. * @param remoteConfig - The {@link RemoteConfig} instance. * @returns A `Promise` which resolves to true if the current call activated the fetched configs. * If the fetched configs were already activated, the `Promise` will resolve to false. * * @public */ async function activate(remoteConfig) { const rc = util.getModularInstance(remoteConfig); const [lastSuccessfulFetchResponse, activeConfigEtag] = await Promise.all([ rc._storage.getLastSuccessfulFetchResponse(), rc._storage.getActiveConfigEtag() ]); if (!lastSuccessfulFetchResponse || !lastSuccessfulFetchResponse.config || !lastSuccessfulFetchResponse.eTag || !lastSuccessfulFetchResponse.templateVersion || lastSuccessfulFetchResponse.eTag === activeConfigEtag) { // Either there is no successful fetched config, or is the same as current active // config. return false; } await Promise.all([ rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config), rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag), rc._storage.setActiveConfigTemplateVersion(lastSuccessfulFetchResponse.templateVersion) ]); return true; } /** * Ensures the last activated config are available to the getters. * @param remoteConfig - The {@link RemoteConfig} instance. * * @returns A `Promise` that resolves when the last activated config is available to the getters. * @public */ function ensureInitialized(remoteConfig) { const rc = util.getModularInstance(remoteConfig); if (!rc._initializePromise) { rc._initializePromise = rc._storageCache.loadFromStorage().then(() => { rc._isInitializationComplete = true; }); } return rc._initializePromise; } /** * Fetches and caches configuration from the Remote Config service. * @param remoteConfig - The {@link RemoteConfig} instance. * @public */ async function fetchConfig(remoteConfig) { const rc = util.getModularInstance(remoteConfig); // Aborts the request after the given timeout, causing the fetch call to // reject with an `AbortError`. // //

Aborting after the request completes is a no-op, so we don't need a // corresponding `clearTimeout`. // // Locating abort logic here because: // * it uses a developer setting (timeout) // * it applies to all retries (like curl's max-time arg) // * it is consistent with the Fetch API's signal input const abortSignal = new RemoteConfigAbortSignal(); setTimeout(async () => { // Note a very low delay, eg < 10ms, can elapse before listeners are initialized. abortSignal.abort(); }, rc.settings.fetchTimeoutMillis); const customSignals = rc._storageCache.getCustomSignals(); if (customSignals) { rc._logger.debug(`Fetching config with custom signals: ${JSON.stringify(customSignals)}`); } // Catches *all* errors thrown by client so status can be set consistently. try { await rc._client.fetch({ cacheMaxAgeMillis: rc.settings.minimumFetchIntervalMillis, signal: abortSignal, customSignals }); await rc._storageCache.setLastFetchStatus('success'); } catch (e) { const lastFetchStatus = hasErrorCode(e, "fetch-throttle" /* ErrorCode.FETCH_THROTTLE */) ? 'throttle' : 'failure'; await rc._storageCache.setLastFetchStatus(lastFetchStatus); throw e; } } /** * Gets all config. * * @param remoteConfig - The {@link RemoteConfig} instance. * @returns All config. * * @public */ function getAll(remoteConfig) { const rc = util.getModularInstance(remoteConfig); return getAllKeys(rc._storageCache.getActiveConfig(), rc.defaultConfig).reduce((allConfigs, key) => { allConfigs[key] = getValue(remoteConfig, key); return allConfigs; }, {}); } /** * Gets the value for the given key as a boolean. * * Convenience method for calling remoteConfig.getValue(key).asBoolean(). * * @param remoteConfig - The {@link RemoteConfig} instance. * @param key - The name of the parameter. * * @returns The value for the given key as a boolean. * @public */ function getBoolean(remoteConfig, key) { return getValue(util.getModularInstance(remoteConfig), key).asBoolean(); } /** * Gets the value for the given key as a number. * * Convenience method for calling remoteConfig.getValue(key).asNumber(). * * @param remoteConfig - The {@link RemoteConfig} instance. * @param key - The name of the parameter. * * @returns The value for the given key as a number. * * @public */ function getNumber(remoteConfig, key) { return getValue(util.getModularInstance(remoteConfig), key).asNumber(); } /** * Gets the value for the given key as a string. * Convenience method for calling remoteConfig.getValue(key).asString(). * * @param remoteConfig - The {@link RemoteConfig} instance. * @param key - The name of the parameter. * * @returns The value for the given key as a string. * * @public */ function getString(remoteConfig, key) { return getValue(util.getModularInstance(remoteConfig), key).asString(); } /** * Gets the {@link Value} for the given key. * * @param remoteConfig - The {@link RemoteConfig} instance. * @param key - The name of the parameter. * * @returns The value for the given key. * * @public */ function getValue(remoteConfig, key) { const rc = util.getModularInstance(remoteConfig); if (!rc._isInitializationComplete) { rc._logger.debug(`A value was requested for key "${key}" before SDK initialization completed.` + ' Await on ensureInitialized if the intent was to get a previously activated value.'); } const activeConfig = rc._storageCache.getActiveConfig(); if (activeConfig && activeConfig[key] !== undefined) { return new Value('remote', activeConfig[key]); } else if (rc.defaultConfig && rc.defaultConfig[key] !== undefined) { return new Value('default', String(rc.defaultConfig[key])); } rc._logger.debug(`Returning static value for key "${key}".` + ' Define a default or remote value if this is unintentional.'); return new Value('static'); } /** * Defines the log level to use. * * @param remoteConfig - The {@link RemoteConfig} instance. * @param logLevel - The log level to set. * * @public */ function setLogLevel(remoteConfig, logLevel) { const rc = util.getModularInstance(remoteConfig); switch (logLevel) { case 'debug': rc._logger.logLevel = logger.LogLevel.DEBUG; break; case 'silent': rc._logger.logLevel = logger.LogLevel.SILENT; break; default: rc._logger.logLevel = logger.LogLevel.ERROR; } } /** * Dedupes and returns an array of all the keys of the received objects. */ function getAllKeys(obj1 = {}, obj2 = {}) { return Object.keys({ ...obj1, ...obj2 }); } /** * Sets the custom signals for the app instance. * * @param remoteConfig - The {@link RemoteConfig} instance. * @param customSignals - Map (key, value) of the custom signals to be set for the app instance. If * a key already exists, the value is overwritten. Setting the value of a custom signal to null * unsets the signal. The signals will be persisted locally on the client. * * @public */ async function setCustomSignals(remoteConfig, customSignals) { const rc = util.getModularInstance(remoteConfig); if (Object.keys(customSignals).length === 0) { return; } // eslint-disable-next-line guard-for-in for (const key in customSignals) { if (key.length > RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH) { rc._logger.error(`Custom signal key ${key} is too long, max allowed length is ${RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH}.`); return; } const value = customSignals[key]; if (typeof value === 'string' && value.length > RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH) { rc._logger.error(`Value supplied for custom signal ${key} is too long, max allowed length is ${RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH}.`); return; } } try { await rc._storageCache.setCustomSignals(customSignals); } catch (error) { rc._logger.error(`Error encountered while setting custom signals: ${error}`); } } // TODO: Add public document for the Remote Config Realtime API guide on the Web Platform. /** * Starts listening for real-time config updates from the Remote Config backend and automatically * fetches updates from the Remote Config backend when they are available. * * @remarks * If a connection to the Remote Config backend is not already open, calling this method will * open it. Multiple listeners can be added by calling this method again, but subsequent calls * re-use the same connection to the backend. * * @param remoteConfig - The {@link RemoteConfig} instance. * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates. * @returns An {@link Unsubscribe} function to remove the listener. * * @public */ function onConfigUpdate(remoteConfig, observer) { const rc = util.getModularInstance(remoteConfig); rc._realtimeHandler.addObserver(observer); return () => { rc._realtimeHandler.removeObserver(observer); }; } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Implements the {@link RemoteConfigClient} abstraction with success response caching. * *

Comparable to the browser's Cache API for responses, but the Cache API requires a Service * Worker, which requires HTTPS, which would significantly complicate SDK installation. Also, the * Cache API doesn't support matching entries by time. */ class CachingClient { constructor(client, storage, storageCache, logger) { this.client = client; this.storage = storage; this.storageCache = storageCache; this.logger = logger; } /** * Returns true if the age of the cached fetched configs is less than or equal to * {@link Settings#minimumFetchIntervalInSeconds}. * *

This is comparable to passing `headers = { 'Cache-Control': max-age }` to the * native Fetch API. * *

Visible for testing. */ isCachedDataFresh(cacheMaxAgeMillis, lastSuccessfulFetchTimestampMillis) { // Cache can only be fresh if it's populated. if (!lastSuccessfulFetchTimestampMillis) { this.logger.debug('Config fetch cache check. Cache unpopulated.'); return false; } // Calculates age of cache entry. const cacheAgeMillis = Date.now() - lastSuccessfulFetchTimestampMillis; const isCachedDataFresh = cacheAgeMillis <= cacheMaxAgeMillis; this.logger.debug('Config fetch cache check.' + ` Cache age millis: ${cacheAgeMillis}.` + ` Cache max age millis (minimumFetchIntervalMillis setting): ${cacheMaxAgeMillis}.` + ` Is cache hit: ${isCachedDataFresh}.`); return isCachedDataFresh; } async fetch(request) { // Reads from persisted storage to avoid cache miss if callers don't wait on initialization. const [lastSuccessfulFetchTimestampMillis, lastSuccessfulFetchResponse] = await Promise.all([ this.storage.getLastSuccessfulFetchTimestampMillis(), this.storage.getLastSuccessfulFetchResponse() ]); // Exits early on cache hit. if (lastSuccessfulFetchResponse && this.isCachedDataFresh(request.cacheMaxAgeMillis, lastSuccessfulFetchTimestampMillis)) { return lastSuccessfulFetchResponse; } // Deviates from pure decorator by not honoring a passed ETag since we don't have a public API // that allows the caller to pass an ETag. request.eTag = lastSuccessfulFetchResponse && lastSuccessfulFetchResponse.eTag; // Falls back to service on cache miss. const response = await this.client.fetch(request); // Fetch throws for non-success responses, so success is guaranteed here. const storageOperations = [ // Uses write-through cache for consistency with synchronous public API. this.storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()) ]; if (response.status === 200) { // Caches response only if it has changed, ie non-304 responses. storageOperations.push(this.storage.setLastSuccessfulFetchResponse(response)); } await Promise.all(storageOperations); return response; } } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Attempts to get the most accurate browser language setting. * *

Adapted from getUserLanguage in packages/auth/src/utils.js for TypeScript. * *

Defers default language specification to server logic for consistency. * * @param navigatorLanguage Enables tests to override read-only {@link NavigatorLanguage}. */ function getUserLanguage(navigatorLanguage = navigator) { return ( // Most reliable, but only supported in Chrome/Firefox. (navigatorLanguage.languages && navigatorLanguage.languages[0]) || // Supported in most browsers, but returns the language of the browser // UI, not the language set in browser settings. navigatorLanguage.language // Polyfill otherwise. ); } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Implements the Client abstraction for the Remote Config REST API. */ class RestClient { constructor(firebaseInstallations, sdkVersion, namespace, projectId, apiKey, appId) { this.firebaseInstallations = firebaseInstallations; this.sdkVersion = sdkVersion; this.namespace = namespace; this.projectId = projectId; this.apiKey = apiKey; this.appId = appId; } /** * Fetches from the Remote Config REST API. * * @throws a {@link ErrorCode.FETCH_NETWORK} error if {@link GlobalFetch#fetch} can't * connect to the network. * @throws a {@link ErrorCode.FETCH_PARSE} error if {@link Response#json} can't parse the * fetch response. * @throws a {@link ErrorCode.FETCH_STATUS} error if the service returns an HTTP error status. */ async fetch(request) { const [installationId, installationToken] = await Promise.all([ this.firebaseInstallations.getId(), this.firebaseInstallations.getToken() ]); const urlBase = window.FIREBASE_REMOTE_CONFIG_URL_BASE || 'https://firebaseremoteconfig.googleapis.com'; const url = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:fetch?key=${this.apiKey}`; const headers = { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', // Deviates from pure decorator by not passing max-age header since we don't currently have // service behavior using that header. 'If-None-Match': request.eTag || '*' // TODO: Add this header once CORS error is fixed internally. //'X-Firebase-RC-Fetch-Type': `${fetchType}/${fetchAttempt}` }; const requestBody = { /* eslint-disable camelcase */ sdk_version: this.sdkVersion, app_instance_id: installationId, app_instance_id_token: installationToken, app_id: this.appId, language_code: getUserLanguage(), custom_signals: request.customSignals /* eslint-enable camelcase */ }; const options = { method: 'POST', headers, body: JSON.stringify(requestBody) }; // This logic isn't REST-specific, but shimming abort logic isn't worth another decorator. const fetchPromise = fetch(url, options); const timeoutPromise = new Promise((_resolve, reject) => { // Maps async event listener to Promise API. request.signal.addEventListener(() => { // Emulates https://heycam.github.io/webidl/#aborterror const error = new Error('The operation was aborted.'); error.name = 'AbortError'; reject(error); }); }); let response; try { await Promise.race([fetchPromise, timeoutPromise]); response = await fetchPromise; } catch (originalError) { let errorCode = "fetch-client-network" /* ErrorCode.FETCH_NETWORK */; if (originalError?.name === 'AbortError') { errorCode = "fetch-timeout" /* ErrorCode.FETCH_TIMEOUT */; } throw ERROR_FACTORY.create(errorCode, { originalErrorMessage: originalError?.message }); } let status = response.status; // Normalizes nullable header to optional. const responseEtag = response.headers.get('ETag') || undefined; let config; let state; let templateVersion; // JSON parsing throws SyntaxError if the response body isn't a JSON string. // Requesting application/json and checking for a 200 ensures there's JSON data. if (response.status === 200) { let responseBody; try { responseBody = await response.json(); } catch (originalError) { throw ERROR_FACTORY.create("fetch-client-parse" /* ErrorCode.FETCH_PARSE */, { originalErrorMessage: originalError?.message }); } config = responseBody['entries']; state = responseBody['state']; templateVersion = responseBody['templateVersion']; } // Normalizes based on legacy state. if (state === 'INSTANCE_STATE_UNSPECIFIED') { status = 500; } else if (state === 'NO_CHANGE') { status = 304; } else if (state === 'NO_TEMPLATE' || state === 'EMPTY_CONFIG') { // These cases can be fixed remotely, so normalize to safe value. config = {}; } // Normalize to exception-based control flow for non-success cases. // Encapsulates HTTP specifics in this class as much as possible. Status is still the best for // differentiating success states (200 from 304; the state body param is undefined in a // standard 304). if (status !== 304 && status !== 200) { throw ERROR_FACTORY.create("fetch-status" /* ErrorCode.FETCH_STATUS */, { httpStatus: status }); } return { status, eTag: responseEtag, config, templateVersion }; } } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Supports waiting on a backoff by: * *

* *

Visible for testing. */ function setAbortableTimeout(signal, throttleEndTimeMillis) { return new Promise((resolve, reject) => { // Derives backoff from given end time, normalizing negative numbers to zero. const backoffMillis = Math.max(throttleEndTimeMillis - Date.now(), 0); const timeout = setTimeout(resolve, backoffMillis); // Adds listener, rather than sets onabort, because signal is a shared object. signal.addEventListener(() => { clearTimeout(timeout); // If the request completes before this timeout, the rejection has no effect. reject(ERROR_FACTORY.create("fetch-throttle" /* ErrorCode.FETCH_THROTTLE */, { throttleEndTimeMillis })); }); }); } /** * Returns true if the {@link Error} indicates a fetch request may succeed later. */ function isRetriableError(e) { if (!(e instanceof util.FirebaseError) || !e.customData) { return false; } // Uses string index defined by ErrorData, which FirebaseError implements. const httpStatus = Number(e.customData['httpStatus']); return (httpStatus === 429 || httpStatus === 500 || httpStatus === 503 || httpStatus === 504); } /** * Decorates a Client with retry logic. * *

Comparable to CachingClient, but uses backoff logic instead of cache max age and doesn't cache * responses (because the SDK has no use for error responses). */ class RetryingClient { constructor(client, storage) { this.client = client; this.storage = storage; } async fetch(request) { const throttleMetadata = (await this.storage.getThrottleMetadata()) || { backoffCount: 0, throttleEndTimeMillis: Date.now() }; return this.attemptFetch(request, throttleMetadata); } /** * A recursive helper for attempting a fetch request repeatedly. * * @throws any non-retriable errors. */ async attemptFetch(request, { throttleEndTimeMillis, backoffCount }) { // Starts with a (potentially zero) timeout to support resumption from stored state. // Ensures the throttle end time is honored if the last attempt timed out. // Note the SDK will never make a request if the fetch timeout expires at this point. await setAbortableTimeout(request.signal, throttleEndTimeMillis); try { const response = await this.client.fetch(request); // Note the SDK only clears throttle state if response is success or non-retriable. await this.storage.deleteThrottleMetadata(); return response; } catch (e) { if (!isRetriableError(e)) { throw e; } // Increments backoff state. const throttleMetadata = { throttleEndTimeMillis: Date.now() + util.calculateBackoffMillis(backoffCount), backoffCount: backoffCount + 1 }; // Persists state. await this.storage.setThrottleMetadata(throttleMetadata); return this.attemptFetch(request, throttleMetadata); } } } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute const DEFAULT_CACHE_MAX_AGE_MILLIS = 12 * 60 * 60 * 1000; // Twelve hours. /** * Encapsulates business logic mapping network and storage dependencies to the public SDK API. * * See {@link https://github.com/firebase/firebase-js-sdk/blob/main/packages/firebase/compat/index.d.ts|interface documentation} for method descriptions. */ class RemoteConfig { get fetchTimeMillis() { return this._storageCache.getLastSuccessfulFetchTimestampMillis() || -1; } get lastFetchStatus() { return this._storageCache.getLastFetchStatus() || 'no-fetch-yet'; } constructor( // Required by FirebaseServiceFactory interface. app, // JS doesn't support private yet // (https://github.com/tc39/proposal-class-fields#private-fields), so we hint using an // underscore prefix. /** * @internal */ _client, /** * @internal */ _storageCache, /** * @internal */ _storage, /** * @internal */ _logger, /** * @internal */ _realtimeHandler) { this.app = app; this._client = _client; this._storageCache = _storageCache; this._storage = _storage; this._logger = _logger; this._realtimeHandler = _realtimeHandler; /** * Tracks completion of initialization promise. * @internal */ this._isInitializationComplete = false; this.settings = { fetchTimeoutMillis: DEFAULT_FETCH_TIMEOUT_MILLIS, minimumFetchIntervalMillis: DEFAULT_CACHE_MAX_AGE_MILLIS }; this.defaultConfig = {}; } } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * Converts an error event associated with a {@link IDBRequest} to a {@link FirebaseError}. */ function toFirebaseError(event, errorCode) { const originalError = event.target.error || undefined; return ERROR_FACTORY.create(errorCode, { originalErrorMessage: originalError && originalError?.message }); } /** * A general-purpose store keyed by app + namespace + {@link * ProjectNamespaceKeyFieldValue}. * *

The Remote Config SDK can be used with multiple app installations, and each app can interact * with multiple namespaces, so this store uses app (ID + name) and namespace as common parent keys * for a set of key-value pairs. See {@link Storage#createCompositeKey}. * *

Visible for testing. */ const APP_NAMESPACE_STORE = 'app_namespace_store'; const DB_NAME = 'firebase_remote_config'; const DB_VERSION = 1; // Visible for testing. function openDatabase() { return new Promise((resolve, reject) => { try { const request = indexedDB.open(DB_NAME, DB_VERSION); request.onerror = event => { reject(toFirebaseError(event, "storage-open" /* ErrorCode.STORAGE_OPEN */)); }; request.onsuccess = event => { resolve(event.target.result); }; request.onupgradeneeded = event => { const db = event.target.result; // We don't use 'break' in this switch statement, the fall-through // behavior is what we want, because if there are multiple versions between // the old version and the current version, we want ALL the migrations // that correspond to those versions to run, not only the last one. // eslint-disable-next-line default-case switch (event.oldVersion) { case 0: db.createObjectStore(APP_NAMESPACE_STORE, { keyPath: 'compositeKey' }); } }; } catch (error) { reject(ERROR_FACTORY.create("storage-open" /* ErrorCode.STORAGE_OPEN */, { originalErrorMessage: error?.message })); } }); } /** * Abstracts data persistence. */ class Storage { getLastFetchStatus() { return this.get('last_fetch_status'); } setLastFetchStatus(status) { return this.set('last_fetch_status', status); } // This is comparable to a cache entry timestamp. If we need to expire other data, we could // consider adding timestamp to all storage records and an optional max age arg to getters. getLastSuccessfulFetchTimestampMillis() { return this.get('last_successful_fetch_timestamp_millis'); } setLastSuccessfulFetchTimestampMillis(timestamp) { return this.set('last_successful_fetch_timestamp_millis', timestamp); } getLastSuccessfulFetchResponse() { return this.get('last_successful_fetch_response'); } setLastSuccessfulFetchResponse(response) { return this.set('last_successful_fetch_response', response); } getActiveConfig() { return this.get('active_config'); } setActiveConfig(config) { return this.set('active_config', config); } getActiveConfigEtag() { return this.get('active_config_etag'); } setActiveConfigEtag(etag) { return this.set('active_config_etag', etag); } getThrottleMetadata() { return this.get('throttle_metadata'); } setThrottleMetadata(metadata) { return this.set('throttle_metadata', metadata); } deleteThrottleMetadata() { return this.delete('throttle_metadata'); } getCustomSignals() { return this.get('custom_signals'); } getRealtimeBackoffMetadata() { return this.get('realtime_backoff_metadata'); } setRealtimeBackoffMetadata(realtimeMetadata) { return this.set('realtime_backoff_metadata', realtimeMetadata); } getActiveConfigTemplateVersion() { return this.get('last_known_template_version'); } setActiveConfigTemplateVersion(version) { return this.set('last_known_template_version', version); } } class IndexedDbStorage extends Storage { /** * @param appId enables storage segmentation by app (ID + name). * @param appName enables storage segmentation by app (ID + name). * @param namespace enables storage segmentation by namespace. */ constructor(appId, appName, namespace, openDbPromise = openDatabase()) { super(); this.appId = appId; this.appName = appName; this.namespace = namespace; this.openDbPromise = openDbPromise; } async setCustomSignals(customSignals) { const db = await this.openDbPromise; const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); const storedSignals = await this.getWithTransaction('custom_signals', transaction); const updatedSignals = mergeCustomSignals(customSignals, storedSignals || {}); await this.setWithTransaction('custom_signals', updatedSignals, transaction); return updatedSignals; } /** * Gets a value from the database using the provided transaction. * * @param key The key of the value to get. * @param transaction The transaction to use for the operation. * @returns The value associated with the key, or undefined if no such value exists. */ async getWithTransaction(key, transaction) { return new Promise((resolve, reject) => { const objectStore = transaction.objectStore(APP_NAMESPACE_STORE); const compositeKey = this.createCompositeKey(key); try { const request = objectStore.get(compositeKey); request.onerror = event => { reject(toFirebaseError(event, "storage-get" /* ErrorCode.STORAGE_GET */)); }; request.onsuccess = event => { const result = event.target.result; if (result) { resolve(result.value); } else { resolve(undefined); } }; } catch (e) { reject(ERROR_FACTORY.create("storage-get" /* ErrorCode.STORAGE_GET */, { originalErrorMessage: e?.message })); } }); } /** * Sets a value in the database using the provided transaction. * * @param key The key of the value to set. * @param value The value to set. * @param transaction The transaction to use for the operation. * @returns A promise that resolves when the operation is complete. */ async setWithTransaction(key, value, transaction) { return new Promise((resolve, reject) => { const objectStore = transaction.objectStore(APP_NAMESPACE_STORE); const compositeKey = this.createCompositeKey(key); try { const request = objectStore.put({ compositeKey, value }); request.onerror = (event) => { reject(toFirebaseError(event, "storage-set" /* ErrorCode.STORAGE_SET */)); }; request.onsuccess = () => { resolve(); }; } catch (e) { reject(ERROR_FACTORY.create("storage-set" /* ErrorCode.STORAGE_SET */, { originalErrorMessage: e?.message })); } }); } async get(key) { const db = await this.openDbPromise; const transaction = db.transaction([APP_NAMESPACE_STORE], 'readonly'); return this.getWithTransaction(key, transaction); } async set(key, value) { const db = await this.openDbPromise; const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); return this.setWithTransaction(key, value, transaction); } async delete(key) { const db = await this.openDbPromise; return new Promise((resolve, reject) => { const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); const objectStore = transaction.objectStore(APP_NAMESPACE_STORE); const compositeKey = this.createCompositeKey(key); try { const request = objectStore.delete(compositeKey); request.onerror = (event) => { reject(toFirebaseError(event, "storage-delete" /* ErrorCode.STORAGE_DELETE */)); }; request.onsuccess = () => { resolve(); }; } catch (e) { reject(ERROR_FACTORY.create("storage-delete" /* ErrorCode.STORAGE_DELETE */, { originalErrorMessage: e?.message })); } }); } // Facilitates composite key functionality (which is unsupported in IE). createCompositeKey(key) { return [this.appId, this.appName, this.namespace, key].join(); } } class InMemoryStorage extends Storage { constructor() { super(...arguments); this.storage = {}; } async get(key) { return Promise.resolve(this.storage[key]); } async set(key, value) { this.storage[key] = value; return Promise.resolve(undefined); } async delete(key) { this.storage[key] = undefined; return Promise.resolve(); } async setCustomSignals(customSignals) { const storedSignals = (this.storage['custom_signals'] || {}); this.storage['custom_signals'] = mergeCustomSignals(customSignals, storedSignals); return Promise.resolve(this.storage['custom_signals']); } } function mergeCustomSignals(customSignals, storedSignals) { const combinedSignals = { ...storedSignals, ...customSignals }; // Filter out key-value assignments with null values since they are signals being unset const updatedSignals = Object.fromEntries(Object.entries(combinedSignals) .filter(([_, v]) => v !== null) .map(([k, v]) => { // Stringify numbers to store a map of string keys and values which can be sent // as-is in a fetch call. if (typeof v === 'number') { return [k, v.toString()]; } return [k, v]; })); // Throw an error if the number of custom signals to be stored exceeds the limit if (Object.keys(updatedSignals).length > RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS) { throw ERROR_FACTORY.create("custom-signal-max-allowed-signals" /* ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS */, { maxSignals: RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS }); } return updatedSignals; } /** * @license * Copyright 2019 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ /** * A memory cache layer over storage to support the SDK's synchronous read requirements. */ class StorageCache { constructor(storage) { this.storage = storage; } /** * Memory-only getters */ getLastFetchStatus() { return this.lastFetchStatus; } getLastSuccessfulFetchTimestampMillis() { return this.lastSuccessfulFetchTimestampMillis; } getActiveConfig() { return this.activeConfig; } getCustomSignals() { return this.customSignals; } /** * Read-ahead getter */ async loadFromStorage() { const lastFetchStatusPromise = this.storage.getLastFetchStatus(); const lastSuccessfulFetchTimestampMillisPromise = this.storage.getLastSuccessfulFetchTimestampMillis(); const activeConfigPromise = this.storage.getActiveConfig(); const customSignalsPromise = this.storage.getCustomSignals(); // Note: // 1. we consistently check for undefined to avoid clobbering defined values // in memory // 2. we defer awaiting to improve readability, as opposed to destructuring // a Promise.all result, for example const lastFetchStatus = await lastFetchStatusPromise; if (lastFetchStatus) { this.lastFetchStatus = lastFetchStatus; } const lastSuccessfulFetchTimestampMillis = await lastSuccessfulFetchTimestampMillisPromise; if (lastSuccessfulFetchTimestampMillis) { this.lastSuccessfulFetchTimestampMillis = lastSuccessfulFetchTimestampMillis; } const activeConfig = await activeConfigPromise; if (activeConfig) { this.activeConfig = activeConfig; } const customSignals = await customSignalsPromise; if (customSignals) { this.customSignals = customSignals; } } /** * Write-through setters */ setLastFetchStatus(status) { this.lastFetchStatus = status; return this.storage.setLastFetchStatus(status); } setLastSuccessfulFetchTimestampMillis(timestampMillis) { this.lastSuccessfulFetchTimestampMillis = timestampMillis; return this.storage.setLastSuccessfulFetchTimestampMillis(timestampMillis); } setActiveConfig(activeConfig) { this.activeConfig = activeConfig; return this.storage.setActiveConfig(activeConfig); } async setCustomSignals(customSignals) { this.customSignals = await this.storage.setCustomSignals(customSignals); } } /** * @license * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // 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. /** * Base class to be used if you want to emit events. Call the constructor with * the set of allowed event names. */ class EventEmitter { constructor(allowedEvents_) { this.allowedEvents_ = allowedEvents_; this.listeners_ = {}; util.assert(Array.isArray(allowedEvents_) && allowedEvents_.length > 0, 'Requires a non-empty array'); } /** * To be called by derived classes to trigger events. */ trigger(eventType, ...varArgs) { if (Array.isArray(this.listeners_[eventType])) { // Clone the list, since callbacks could add/remove listeners. const listeners = [...this.listeners_[eventType]]; for (let i = 0; i < listeners.length; i++) { listeners[i].callback.apply(listeners[i].context, varArgs); } } } on(eventType, callback, context) { this.validateEventType_(eventType); this.listeners_[eventType] = this.listeners_[eventType] || []; this.listeners_[eventType].push({ callback, context }); const eventData = this.getInitialEvent(eventType); if (eventData) { //@ts-ignore callback.apply(context, eventData); } } off(eventType, callback, context) { this.validateEventType_(eventType); const listeners = this.listeners_[eventType] || []; for (let i = 0; i < listeners.length; i++) { if (listeners[i].callback === callback && (!context || context === listeners[i].context)) { listeners.splice(i, 1); return; } } } validateEventType_(eventType) { util.assert(this.allowedEvents_.find(et => { return et === eventType; }), 'Unknown event: ' + eventType); } } /** * @license * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // 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. class VisibilityMonitor extends EventEmitter { static getInstance() { return new VisibilityMonitor(); } constructor() { super(['visible']); let hidden; let visibilityChange; if (typeof document !== 'undefined' && typeof document.addEventListener !== 'undefined') { if (typeof document['hidden'] !== 'undefined') { // Opera 12.10 and Firefox 18 and later support visibilityChange = 'visibilitychange'; hidden = 'hidden'; } // @ts-ignore else if (typeof document['mozHidden'] !== 'undefined') { visibilityChange = 'mozvisibilitychange'; hidden = 'mozHidden'; } // @ts-ignore else if (typeof document['msHidden'] !== 'undefined') { visibilityChange = 'msvisibilitychange'; hidden = 'msHidden'; } // @ts-ignore else if (typeof document['webkitHidden'] !== 'undefined') { visibilityChange = 'webkitvisibilitychange'; hidden = 'webkitHidden'; } } // Initially, we always assume we are visible. This ensures that in browsers // without page visibility support or in cases where we are never visible // (e.g. chrome extension), we act as if we are visible, i.e. don't delay // reconnects this.visible_ = true; // @ts-ignore if (visibilityChange) { document.addEventListener(visibilityChange, () => { // @ts-ignore const visible = !document[hidden]; if (visible !== this.visible_) { this.visible_ = visible; this.trigger('visible', visible); } }, false); } } getInitialEvent(eventType) { util.assert(eventType === 'visible', 'Unknown event type: ' + eventType); return [this.visible_]; } } /** * @license * Copyright 2025 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ const API_KEY_HEADER = 'X-Goog-Api-Key'; const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; const ORIGINAL_RETRIES = 8; const MAXIMUM_FETCH_ATTEMPTS = 3; const NO_BACKOFF_TIME_IN_MILLIS = -1; const NO_FAILED_REALTIME_STREAMS = 0; const REALTIME_DISABLED_KEY = 'featureDisabled'; const REALTIME_RETRY_INTERVAL = 'retryIntervalSeconds'; const TEMPLATE_VERSION_KEY = 'latestTemplateVersionNumber'; class RealtimeHandler { constructor(firebaseInstallations, storage, sdkVersion, namespace, projectId, apiKey, appId, logger, storageCache, cachingClient) { this.firebaseInstallations = firebaseInstallations; this.storage = storage; this.sdkVersion = sdkVersion; this.namespace = namespace; this.projectId = projectId; this.apiKey = apiKey; this.appId = appId; this.logger = logger; this.storageCache = storageCache; this.cachingClient = cachingClient; this.observers = new Set(); this.isConnectionActive = false; this.isRealtimeDisabled = false; this.httpRetriesRemaining = ORIGINAL_RETRIES; this.isInBackground = false; this.decoder = new TextDecoder('utf-8'); this.isClosingConnection = false; this.propagateError = (e) => this.observers.forEach(o => o.error?.(e)); /** * HTTP status code that the Realtime client should retry on. */ this.isStatusCodeRetryable = (statusCode) => { const retryableStatusCodes = [ 408, // Request Timeout 429, // Too Many Requests 502, // Bad Gateway 503, // Service Unavailable 504 // Gateway Timeout ]; return !statusCode || retryableStatusCodes.includes(statusCode); }; void this.setRetriesRemaining(); void VisibilityMonitor.getInstance().on('visible', this.onVisibilityChange, this); } async setRetriesRemaining() { // Retrieve number of remaining retries from last session. The minimum retry count being one. const metadata = await this.storage.getRealtimeBackoffMetadata(); const numFailedStreams = metadata?.numFailedStreams || 0; this.httpRetriesRemaining = Math.max(ORIGINAL_RETRIES - numFailedStreams, 1); } /** * Increment the number of failed stream attempts, increase the backoff duration, set the backoff * end time to "backoff duration" after `lastFailedStreamTime` and persist the new * values to storage metadata. */ async updateBackoffMetadataWithLastFailedStreamConnectionTime(lastFailedStreamTime) { const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0) + 1; const backoffMillis = util.calculateBackoffMillis(numFailedStreams, 60000, 2); await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: new Date(lastFailedStreamTime.getTime() + backoffMillis), numFailedStreams }); } /** * Increase the backoff duration with a new end time based on Retry Interval. */ async updateBackoffMetadataWithRetryInterval(retryIntervalSeconds) { const currentTime = Date.now(); const backoffDurationInMillis = retryIntervalSeconds * 1000; const backoffEndTime = new Date(currentTime + backoffDurationInMillis); const numFailedStreams = 0; await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: backoffEndTime, numFailedStreams }); await this.retryHttpConnectionWhenBackoffEnds(); } /** * Closes the realtime HTTP connection. * Note: This method is designed to be called only once at a time. * If a call is already in progress, subsequent calls will be ignored. */ async closeRealtimeHttpConnection() { if (this.isClosingConnection) { return; } this.isClosingConnection = true; try { if (this.reader) { await this.reader.cancel(); } } catch (e) { // The network connection was lost, so cancel() failed. // This is expected in a disconnected state, so we can safely ignore the error. this.logger.debug('Failed to cancel the reader, connection was lost.'); } finally { this.reader = undefined; } if (this.controller) { await this.controller.abort(); this.controller = undefined; } this.isClosingConnection = false; } async resetRealtimeBackoff() { await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: new Date(-1), numFailedStreams: 0 }); } resetRetryCount() { this.httpRetriesRemaining = ORIGINAL_RETRIES; } /** * Assembles the request headers and body and executes the fetch request to * establish the real-time streaming connection. This is the "worker" method * that performs the actual network communication. */ async establishRealtimeConnection(url, installationId, installationTokenResult, signal) { const eTagValue = await this.storage.getActiveConfigEtag(); const lastKnownVersionNumber = await this.storage.getActiveConfigTemplateVersion(); const headers = { [API_KEY_HEADER]: this.apiKey, [INSTALLATIONS_AUTH_TOKEN_HEADER]: installationTokenResult, 'Content-Type': 'application/json', 'Accept': 'application/json', 'If-None-Match': eTagValue || '*', 'Content-Encoding': 'gzip' }; const requestBody = { project: this.projectId, namespace: this.namespace, lastKnownVersionNumber, appId: this.appId, sdkVersion: this.sdkVersion, appInstanceId: installationId }; const response = await fetch(url, { method: 'POST', headers, body: JSON.stringify(requestBody), signal }); return response; } getRealtimeUrl() { const urlBase = window.FIREBASE_REMOTE_CONFIG_URL_BASE || 'https://firebaseremoteconfigrealtime.googleapis.com'; const urlString = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:streamFetchInvalidations?key=${this.apiKey}`; return new URL(urlString); } async createRealtimeConnection() { const [installationId, installationTokenResult] = await Promise.all([ this.firebaseInstallations.getId(), this.firebaseInstallations.getToken(false) ]); this.controller = new AbortController(); const url = this.getRealtimeUrl(); const realtimeConnection = await this.establishRealtimeConnection(url, installationId, installationTokenResult, this.controller.signal); return realtimeConnection; } /** * Retries HTTP stream connection asyncly in random time intervals. */ async retryHttpConnectionWhenBackoffEnds() { let backoffMetadata = await this.storage.getRealtimeBackoffMetadata(); if (!backoffMetadata) { backoffMetadata = { backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), numFailedStreams: NO_FAILED_REALTIME_STREAMS }; } const backoffEndTime = new Date(backoffMetadata.backoffEndTimeMillis).getTime(); const currentTime = Date.now(); const retryMillis = Math.max(0, backoffEndTime - currentTime); await this.makeRealtimeHttpConnection(retryMillis); } setIsHttpConnectionRunning(connectionRunning) { this.isConnectionActive = connectionRunning; } /** * Combines the check and set operations to prevent multiple asynchronous * calls from redundantly starting an HTTP connection. This ensures that * only one attempt is made at a time. */ checkAndSetHttpConnectionFlagIfNotRunning() { const canMakeConnection = this.canEstablishStreamConnection(); if (canMakeConnection) { this.setIsHttpConnectionRunning(true); } return canMakeConnection; } fetchResponseIsUpToDate(fetchResponse, lastKnownVersion) { // If there is a config, make sure its version is >= the last known version. if (fetchResponse.config != null && fetchResponse.templateVersion) { return fetchResponse.templateVersion >= lastKnownVersion; } // If there isn't a config, return true if the fetch was successful and backend had no update. // Else, it returned an out of date config. return this.storageCache.getLastFetchStatus() === 'success'; } parseAndValidateConfigUpdateMessage(message) { const left = message.indexOf('{'); const right = message.indexOf('}', left); if (left < 0 || right < 0) { return ''; } return left >= right ? '' : message.substring(left, right + 1); } isEventListenersEmpty() { return this.observers.size === 0; } getRandomInt(max) { return Math.floor(Math.random() * max); } executeAllListenerCallbacks(configUpdate) { this.observers.forEach(observer => observer.next(configUpdate)); } /** * Compares two configuration objects and returns a set of keys that have changed. * A key is considered changed if it's new, removed, or has a different value. */ getChangedParams(newConfig, oldConfig) { const changedKeys = new Set(); const newKeys = new Set(Object.keys(newConfig || {})); const oldKeys = new Set(Object.keys(oldConfig || {})); for (const key of newKeys) { if (!oldKeys.has(key) || newConfig[key] !== oldConfig[key]) { changedKeys.add(key); } } for (const key of oldKeys) { if (!newKeys.has(key)) { changedKeys.add(key); } } return changedKeys; } async fetchLatestConfig(remainingAttempts, targetVersion) { const remainingAttemptsAfterFetch = remainingAttempts - 1; const currentAttempt = MAXIMUM_FETCH_ATTEMPTS - remainingAttemptsAfterFetch; const customSignals = this.storageCache.getCustomSignals(); if (customSignals) { this.logger.debug(`Fetching config with custom signals: ${JSON.stringify(customSignals)}`); } const abortSignal = new RemoteConfigAbortSignal(); try { const fetchRequest = { cacheMaxAgeMillis: 0, signal: abortSignal, customSignals, fetchType: 'REALTIME', fetchAttempt: currentAttempt }; const fetchResponse = await this.cachingClient.fetch(fetchRequest); let activatedConfigs = await this.storage.getActiveConfig(); if (!this.fetchResponseIsUpToDate(fetchResponse, targetVersion)) { this.logger.debug("Fetched template version is the same as SDK's current version." + ' Retrying fetch.'); // Continue fetching until template version number is greater than current. await this.autoFetch(remainingAttemptsAfterFetch, targetVersion); return; } if (fetchResponse.config == null) { this.logger.debug('The fetch succeeded, but the backend had no updates.'); return; } if (activatedConfigs == null) { activatedConfigs = {}; } const updatedKeys = this.getChangedParams(fetchResponse.config, activatedConfigs); if (updatedKeys.size === 0) { this.logger.debug('Config was fetched, but no params changed.'); return; } const configUpdate = { getUpdatedKeys() { return new Set(updatedKeys); } }; this.executeAllListenerCallbacks(configUpdate); } catch (e) { const errorMessage = e instanceof Error ? e.message : String(e); const error = ERROR_FACTORY.create("update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */, { originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}` }); this.propagateError(error); } } async autoFetch(remainingAttempts, targetVersion) { if (remainingAttempts === 0) { const error = ERROR_FACTORY.create("update-not-fetched" /* ErrorCode.CONFIG_UPDATE_NOT_FETCHED */, { originalErrorMessage: 'Unable to fetch the latest version of the template.' }); this.propagateError(error); return; } const timeTillFetchSeconds = this.getRandomInt(4); const timeTillFetchInMiliseconds = timeTillFetchSeconds * 1000; await new Promise(resolve => setTimeout(resolve, timeTillFetchInMiliseconds)); await this.fetchLatestConfig(remainingAttempts, targetVersion); } /** * Processes a stream of real-time messages for configuration updates. * This method reassembles fragmented messages, validates and parses the JSON, * and automatically fetches a new config if a newer template version is available. * It also handles server-specified retry intervals and propagates errors for * invalid messages or when real-time updates are disabled. */ async handleNotifications(reader) { let partialConfigUpdateMessage; let currentConfigUpdateMessage = ''; while (true) { const { done, value } = await reader.read(); if (done) { break; } partialConfigUpdateMessage = this.decoder.decode(value, { stream: true }); currentConfigUpdateMessage += partialConfigUpdateMessage; if (partialConfigUpdateMessage.includes('}')) { currentConfigUpdateMessage = this.parseAndValidateConfigUpdateMessage(currentConfigUpdateMessage); if (currentConfigUpdateMessage.length === 0) { continue; } try { const jsonObject = JSON.parse(currentConfigUpdateMessage); if (this.isEventListenersEmpty()) { break; } if (REALTIME_DISABLED_KEY in jsonObject && jsonObject[REALTIME_DISABLED_KEY] === true) { const error = ERROR_FACTORY.create("realtime-unavailable" /* ErrorCode.CONFIG_UPDATE_UNAVAILABLE */, { originalErrorMessage: 'The server is temporarily unavailable. Try again in a few minutes.' }); this.propagateError(error); break; } if (TEMPLATE_VERSION_KEY in jsonObject) { const oldTemplateVersion = await this.storage.getActiveConfigTemplateVersion(); const targetTemplateVersion = Number(jsonObject[TEMPLATE_VERSION_KEY]); if (oldTemplateVersion && targetTemplateVersion > oldTemplateVersion) { await this.autoFetch(MAXIMUM_FETCH_ATTEMPTS, targetTemplateVersion); } } // This field in the response indicates that the realtime request should retry after the // specified interval to establish a long-lived connection. This interval extends the // backoff duration without affecting the number of retries, so it will not enter an // exponential backoff state. if (REALTIME_RETRY_INTERVAL in jsonObject) { const retryIntervalSeconds = Number(jsonObject[REALTIME_RETRY_INTERVAL]); await this.updateBackoffMetadataWithRetryInterval(retryIntervalSeconds); } } catch (e) { this.logger.debug('Unable to parse latest config update message.', e); const errorMessage = e instanceof Error ? e.message : String(e); this.propagateError(ERROR_FACTORY.create("update-message-invalid" /* ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID */, { originalErrorMessage: errorMessage })); } currentConfigUpdateMessage = ''; } } } async listenForNotifications(reader) { try { await this.handleNotifications(reader); } catch (e) { // If the real-time connection is at an unexpected lifecycle state when the app is // backgrounded, it's expected closing the connection will throw an exception. if (!this.isInBackground) { // Otherwise, the real-time server connection was closed due to a transient issue. this.logger.debug('Real-time connection was closed due to an exception.'); } } } /** * Open the real-time connection, begin listening for updates, and auto-fetch when an update is * received. * * If the connection is successful, this method will block on its thread while it reads the * chunk-encoded HTTP body. When the connection closes, it attempts to reestablish the stream. */ async prepareAndBeginRealtimeHttpStream() { if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) { return; } let backoffMetadata = await this.storage.getRealtimeBackoffMetadata(); if (!backoffMetadata) { backoffMetadata = { backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), numFailedStreams: NO_FAILED_REALTIME_STREAMS }; } const backoffEndTime = backoffMetadata.backoffEndTimeMillis.getTime(); if (Date.now() < backoffEndTime) { await this.retryHttpConnectionWhenBackoffEnds(); return; } let response; let responseCode; try { response = await this.createRealtimeConnection(); responseCode = response.status; if (response.ok && response.body) { this.resetRetryCount(); await this.resetRealtimeBackoff(); const reader = response.body.getReader(); this.reader = reader; // Start listening for realtime notifications. await this.listenForNotifications(reader); } } catch (error) { if (this.isInBackground) { // It's possible the app was backgrounded while the connection was open, which // threw an exception trying to read the response. No real error here, so treat // this as a success, even if we haven't read a 200 response code yet. this.resetRetryCount(); } else { //there might have been a transient error so the client will retry the connection. this.logger.debug('Exception connecting to real-time RC backend. Retrying the connection...:', error); } } finally { // Close HTTP connection and associated streams. await this.closeRealtimeHttpConnection(); this.setIsHttpConnectionRunning(false); // Update backoff metadata if the connection failed in the foreground. const connectionFailed = !this.isInBackground && (responseCode === undefined || this.isStatusCodeRetryable(responseCode)); if (connectionFailed) { await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date()); } // If responseCode is null then no connection was made to server and the SDK should still retry. if (connectionFailed || response?.ok) { await this.retryHttpConnectionWhenBackoffEnds(); } else { const errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`; const firebaseError = ERROR_FACTORY.create("stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */, { originalErrorMessage: errorMessage }); this.propagateError(firebaseError); } } } /** * Checks whether connection can be made or not based on some conditions * @returns booelean */ canEstablishStreamConnection() { const hasActiveListeners = this.observers.size > 0; const isNotDisabled = !this.isRealtimeDisabled; const isNoConnectionActive = !this.isConnectionActive; const inForeground = !this.isInBackground; return (hasActiveListeners && isNotDisabled && isNoConnectionActive && inForeground); } async makeRealtimeHttpConnection(delayMillis) { if (!this.canEstablishStreamConnection()) { return; } if (this.httpRetriesRemaining > 0) { this.httpRetriesRemaining--; await new Promise(resolve => setTimeout(resolve, delayMillis)); void this.prepareAndBeginRealtimeHttpStream(); } else if (!this.isInBackground) { const error = ERROR_FACTORY.create("stream-error" /* ErrorCode.CONFIG_UPDATE_STREAM_ERROR */, { originalErrorMessage: 'Unable to connect to the server. Check your connection and try again.' }); this.propagateError(error); } } async beginRealtime() { if (this.observers.size > 0) { await this.makeRealtimeHttpConnection(0); } } /** * Adds an observer to the realtime updates. * @param observer The observer to add. */ addObserver(observer) { this.observers.add(observer); void this.beginRealtime(); } /** * Removes an observer from the realtime updates. * @param observer The observer to remove. */ removeObserver(observer) { if (this.observers.has(observer)) { this.observers.delete(observer); } } /** * Handles changes to the application's visibility state, managing the real-time connection. * * When the application is moved to the background, this method closes the existing * real-time connection to save resources. When the application returns to the * foreground, it attempts to re-establish the connection. */ async onVisibilityChange(visible) { this.isInBackground = !visible; if (!visible) { await this.closeRealtimeHttpConnection(); } else if (visible) { await this.beginRealtime(); } } } /** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ function registerRemoteConfig() { app._registerComponent(new component.Component(RC_COMPONENT_NAME, remoteConfigFactory, "PUBLIC" /* ComponentType.PUBLIC */).setMultipleInstances(true)); app.registerVersion(name, version); // BUILD_TARGET will be replaced by values like esm, cjs, etc during the compilation app.registerVersion(name, version, 'cjs2020'); function remoteConfigFactory(container, { options }) { /* Dependencies */ // getImmediate for FirebaseApp will always succeed const app$1 = container.getProvider('app').getImmediate(); // The following call will always succeed because rc has `import '@firebase/installations'` const installations = container .getProvider('installations-internal') .getImmediate(); // Normalizes optional inputs. const { projectId, apiKey, appId } = app$1.options; if (!projectId) { throw ERROR_FACTORY.create("registration-project-id" /* ErrorCode.REGISTRATION_PROJECT_ID */); } if (!apiKey) { throw ERROR_FACTORY.create("registration-api-key" /* ErrorCode.REGISTRATION_API_KEY */); } if (!appId) { throw ERROR_FACTORY.create("registration-app-id" /* ErrorCode.REGISTRATION_APP_ID */); } const namespace = options?.templateId || 'firebase'; const storage = util.isIndexedDBAvailable() ? new IndexedDbStorage(appId, app$1.name, namespace) : new InMemoryStorage(); const storageCache = new StorageCache(storage); const logger$1 = new logger.Logger(name); // Sets ERROR as the default log level. // See RemoteConfig#setLogLevel for corresponding normalization to ERROR log level. logger$1.logLevel = logger.LogLevel.ERROR; const restClient = new RestClient(installations, // Uses the JS SDK version, by which the RC package version can be deduced, if necessary. app.SDK_VERSION, namespace, projectId, apiKey, appId); const retryingClient = new RetryingClient(restClient, storage); const cachingClient = new CachingClient(retryingClient, storage, storageCache, logger$1); const realtimeHandler = new RealtimeHandler(installations, storage, app.SDK_VERSION, namespace, projectId, apiKey, appId, logger$1, storageCache, cachingClient); const remoteConfigInstance = new RemoteConfig(app$1, cachingClient, storageCache, storage, logger$1, realtimeHandler); // Starts warming cache. // eslint-disable-next-line @typescript-eslint/no-floating-promises ensureInitialized(remoteConfigInstance); return remoteConfigInstance; } } /** * @license * Copyright 2020 Google LLC * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ // This API is put in a separate file, so we can stub fetchConfig and activate in tests. // It's not possible to stub standalone functions from the same module. /** * * Performs fetch and activate operations, as a convenience. * * @param remoteConfig - The {@link RemoteConfig} instance. * * @returns A `Promise` which resolves to true if the current call activated the fetched configs. * If the fetched configs were already activated, the `Promise` will resolve to false. * * @public */ async function fetchAndActivate(remoteConfig) { remoteConfig = util.getModularInstance(remoteConfig); await fetchConfig(remoteConfig); return activate(remoteConfig); } /** * This method provides two different checks: * * 1. Check if IndexedDB exists in the browser environment. * 2. Check if the current browser context allows IndexedDB `open()` calls. * * @returns A `Promise` which resolves to true if a {@link RemoteConfig} instance * can be initialized in this environment, or false if it cannot. * @public */ async function isSupported() { if (!util.isIndexedDBAvailable()) { return false; } try { const isDBOpenable = await util.validateIndexedDBOpenable(); return isDBOpenable; } catch (error) { return false; } } /** * The Firebase Remote Config Web SDK. * This SDK does not work in a Node.js environment. * * @packageDocumentation */ /** register component and version */ registerRemoteConfig(); exports.activate = activate; exports.ensureInitialized = ensureInitialized; exports.fetchAndActivate = fetchAndActivate; exports.fetchConfig = fetchConfig; exports.getAll = getAll; exports.getBoolean = getBoolean; exports.getNumber = getNumber; exports.getRemoteConfig = getRemoteConfig; exports.getString = getString; exports.getValue = getValue; exports.isSupported = isSupported; exports.onConfigUpdate = onConfigUpdate; exports.setCustomSignals = setCustomSignals; exports.setLogLevel = setLogLevel; //# sourceMappingURL=index.cjs.js.map