diff --git a/.changeset/lemon-baboons-lick.md b/.changeset/lemon-baboons-lick.md new file mode 100644 index 00000000000..3414d4c9bde --- /dev/null +++ b/.changeset/lemon-baboons-lick.md @@ -0,0 +1,7 @@ +--- +'@firebase/remote-config': minor +'firebase': minor +'@firebase/remote-config-types': minor +--- + +Added support for Realtime Remote Config for the web. This feature introduces a new `onConfigUpdate` API and allows web applications to receive near-instant configuration updates without requiring periodic polling. diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index 213335929dd..a9f5131e0bf 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -5,10 +5,23 @@ ```ts import { FirebaseApp } from '@firebase/app'; +import { FirebaseError } from '@firebase/app'; // @public export function activate(remoteConfig: RemoteConfig): Promise; +// @public +export interface ConfigUpdate { + getUpdatedKeys(): Set; +} + +// @public +export interface ConfigUpdateObserver { + complete: () => void; + error: (error: FirebaseError) => void; + next: (configUpdate: ConfigUpdate) => void; +} + // @public export interface CustomSignals { // (undocumented) @@ -29,11 +42,15 @@ export interface FetchResponse { config?: FirebaseRemoteConfigObject; eTag?: string; status: number; + templateVersion?: number; } // @public export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; +// @public +export type FetchType = 'BASE' | 'REALTIME'; + // @public export interface FirebaseRemoteConfigObject { // (undocumented) @@ -64,6 +81,9 @@ export function isSupported(): Promise; // @public export type LogLevel = 'debug' | 'error' | 'silent'; +// @public +export function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Unsubscribe; + // @public export interface RemoteConfig { app: FirebaseApp; @@ -93,6 +113,9 @@ export function setCustomSignals(remoteConfig: RemoteConfig, customSignals: Cust // @public export function setLogLevel(remoteConfig: RemoteConfig, logLevel: LogLevel): void; +// @public +export type Unsubscribe = () => void; + // @public export interface Value { asBoolean(): boolean; diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index da7c2500894..b2c9dca36c6 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -607,6 +607,10 @@ toc: - title: remote-config path: /docs/reference/js/remote-config.md section: + - title: ConfigUpdate + path: /docs/reference/js/remote-config.configupdate.md + - title: ConfigUpdateObserver + path: /docs/reference/js/remote-config.configupdateobserver.md - title: CustomSignals path: /docs/reference/js/remote-config.customsignals.md - title: FetchResponse diff --git a/docs-devsite/remote-config.configupdate.md b/docs-devsite/remote-config.configupdate.md new file mode 100644 index 00000000000..231c8b1eb1f --- /dev/null +++ b/docs-devsite/remote-config.configupdate.md @@ -0,0 +1,39 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# ConfigUpdate interface +Contains information about which keys have been updated. + +Signature: + +```typescript +export interface ConfigUpdate +``` + +## Methods + +| Method | Description | +| --- | --- | +| [getUpdatedKeys()](./remote-config.configupdate.md#configupdategetupdatedkeys) | Parameter keys whose values have been updated from the currently activated values. Includes keys that are added, deleted, or whose value, value source, or metadata has changed. | + +## ConfigUpdate.getUpdatedKeys() + +Parameter keys whose values have been updated from the currently activated values. Includes keys that are added, deleted, or whose value, value source, or metadata has changed. + +Signature: + +```typescript +getUpdatedKeys(): Set; +``` +Returns: + +Set<string> + diff --git a/docs-devsite/remote-config.configupdateobserver.md b/docs-devsite/remote-config.configupdateobserver.md new file mode 100644 index 00000000000..93f9154bb91 --- /dev/null +++ b/docs-devsite/remote-config.configupdateobserver.md @@ -0,0 +1,59 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# ConfigUpdateObserver interface +Observer interface for receiving real-time Remote Config update notifications. + +NOTE: Although an `complete` callback can be provided, it will never be called because the ConfigUpdate stream is never-ending. + +Signature: + +```typescript +export interface ConfigUpdateObserver +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [complete](./remote-config.configupdateobserver.md#configupdateobservercomplete) | () => void | Called when the stream is gracefully terminated. | +| [error](./remote-config.configupdateobserver.md#configupdateobservererror) | (error: [FirebaseError](./util.firebaseerror.md#firebaseerror_class)) => void | Called if an error occurs during the stream. | +| [next](./remote-config.configupdateobserver.md#configupdateobservernext) | (configUpdate: [ConfigUpdate](./remote-config.configupdate.md#configupdate_interface)) => void | Called when a new ConfigUpdate is available. | + +## ConfigUpdateObserver.complete + +Called when the stream is gracefully terminated. + +Signature: + +```typescript +complete: () => void; +``` + +## ConfigUpdateObserver.error + +Called if an error occurs during the stream. + +Signature: + +```typescript +error: (error: FirebaseError) => void; +``` + +## ConfigUpdateObserver.next + +Called when a new ConfigUpdate is available. + +Signature: + +```typescript +next: (configUpdate: ConfigUpdate) => void; +``` diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md index 414188e72bb..1955dd47492 100644 --- a/docs-devsite/remote-config.fetchresponse.md +++ b/docs-devsite/remote-config.fetchresponse.md @@ -27,6 +27,7 @@ export interface FetchResponse | [config](./remote-config.fetchresponse.md#fetchresponseconfig) | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines the map of parameters returned as "entries" in the fetch response body.

Only defined for 200 responses. | | [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.

Only defined for 200 and 304 responses. | | [status](./remote-config.fetchresponse.md#fetchresponsestatus) | number | The HTTP status, which is useful for differentiating success responses with data from those without.

The Remote Config client is modeled after the native Fetch interface, so HTTP status is first-class.

Disambiguation: the fetch response returns a legacy "state" value that is redundant with the HTTP status code. The former is normalized into the latter. | +| [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. | ## FetchResponse.config @@ -65,3 +66,13 @@ The HTTP status, which is useful for differentiating success responses with data ```typescript status: number; ``` + +## FetchResponse.templateVersion + +The version number of the config template fetched from the server. + +Signature: + +```typescript +templateVersion?: number; +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 58d23cfd647..9233fa5c40c 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -28,6 +28,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | [getNumber(remoteConfig, key)](./remote-config.md#getnumber_476c09f) | Gets the value for the given key as a number.Convenience method for calling remoteConfig.getValue(key).asNumber(). | | [getString(remoteConfig, key)](./remote-config.md#getstring_476c09f) | Gets the value for the given key as a string. Convenience method for calling remoteConfig.getValue(key).asString(). | | [getValue(remoteConfig, key)](./remote-config.md#getvalue_476c09f) | Gets the [Value](./remote-config.value.md#value_interface) for the given key. | +| [onConfigUpdate(remoteConfig, observer)](./remote-config.md#onconfigupdate_8b13b26) | Starts listening for real-time config updates from the Remote Config backend and automatically fetches updates from the RC backend when they are available. | | [setCustomSignals(remoteConfig, customSignals)](./remote-config.md#setcustomsignals_aeeb95e) | Sets the custom signals for the app instance. | | [setLogLevel(remoteConfig, logLevel)](./remote-config.md#setloglevel_039a45b) | Defines the log level to use. | | function() | @@ -37,6 +38,8 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Interface | Description | | --- | --- | +| [ConfigUpdate](./remote-config.configupdate.md#configupdate_interface) | Contains information about which keys have been updated. | +| [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) | Observer interface for receiving real-time Remote Config update notifications.NOTE: Although an complete callback can be provided, it will never be called because the ConfigUpdate stream is never-ending. | | [CustomSignals](./remote-config.customsignals.md#customsignals_interface) | Defines the type for representing custom signals and their values.

The values in CustomSignals must be one of the following types:

  • string
  • number
  • null
| | [FetchResponse](./remote-config.fetchresponse.md#fetchresponse_interface) | Defines a successful response (200 or 304).

Modeled after the native Response interface, but simplified for Remote Config's use case. | | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines a self-descriptive reference for config key-value pairs. | @@ -50,7 +53,9 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Type Alias | Description | | --- | --- | | [FetchStatus](./remote-config.md#fetchstatus) | Summarizes the outcome of the last attempt to fetch config from the Firebase Remote Config server.

  • "no-fetch-yet" indicates the [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) instance has not yet attempted to fetch config, or that SDK initialization is incomplete.
  • "success" indicates the last attempt succeeded.
  • "failure" indicates the last attempt failed.
  • "throttle" indicates the last attempt was rate-limited.
| +| [FetchType](./remote-config.md#fetchtype) | Indicates the type of fetch request.
  • "BASE" indicates a standard fetch request.
  • "REALTIME" indicates a fetch request triggered by a real-time update.
| | [LogLevel](./remote-config.md#loglevel) | Defines levels of Remote Config logging. | +| [Unsubscribe](./remote-config.md#unsubscribe) | A function that unsubscribes from a real-time event stream. | | [ValueSource](./remote-config.md#valuesource) | Indicates the source of a value.
  • "static" indicates the value was defined by a static constant.
  • "default" indicates the value was defined by default config.
  • "remote" indicates the value was defined by fetched config.
| ## function(app, ...) @@ -282,6 +287,31 @@ export declare function getValue(remoteConfig: RemoteConfig, key: string): Value The value for the given key. +### onConfigUpdate(remoteConfig, observer) {:#onconfigupdate_8b13b26} + +Starts listening for real-time config updates from the Remote Config backend and automatically fetches updates from the RC backend when they are available. + +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. + +Signature: + +```typescript +export declare function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Unsubscribe; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| remoteConfig | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) instance. | +| observer | [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) | The [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) to be notified of config updates. | + +Returns: + +[Unsubscribe](./remote-config.md#unsubscribe) + +An [Unsubscribe](./remote-config.md#unsubscribe) function to remove the listener. + ### setCustomSignals(remoteConfig, customSignals) {:#setcustomsignals_aeeb95e} Sets the custom signals for the app instance. @@ -355,6 +385,18 @@ Summarizes the outcome of the last attempt to fetch config from the Firebase Rem export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; ``` +## FetchType + +Indicates the type of fetch request. + +
  • "BASE" indicates a standard fetch request.
  • "REALTIME" indicates a fetch request triggered by a real-time update.
+ +Signature: + +```typescript +export type FetchType = 'BASE' | 'REALTIME'; +``` + ## LogLevel Defines levels of Remote Config logging. @@ -365,6 +407,16 @@ Defines levels of Remote Config logging. export type LogLevel = 'debug' | 'error' | 'silent'; ``` +## Unsubscribe + +A function that unsubscribes from a real-time event stream. + +Signature: + +```typescript +export type Unsubscribe = () => void; +``` + ## ValueSource Indicates the source of a value. diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 1431864edd5..ab514896ce0 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -22,7 +22,9 @@ import { LogLevel as RemoteConfigLogLevel, RemoteConfig, Value, - RemoteConfigOptions + RemoteConfigOptions, + ConfigUpdateObserver, + Unsubscribe } from './public_types'; import { RemoteConfigAbortSignal } from './client/remote_config_fetch_client'; import { @@ -66,6 +68,9 @@ export function getRemoteConfig( 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( @@ -98,6 +103,7 @@ export async function activate(remoteConfig: RemoteConfig): Promise { !lastSuccessfulFetchResponse || !lastSuccessfulFetchResponse.config || !lastSuccessfulFetchResponse.eTag || + !lastSuccessfulFetchResponse.templateVersion || lastSuccessfulFetchResponse.eTag === activeConfigEtag ) { // Either there is no successful fetched config, or is the same as current active @@ -106,7 +112,10 @@ export async function activate(remoteConfig: RemoteConfig): Promise { } await Promise.all([ rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config), - rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag) + rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag), + rc._storage.setActiveConfigTemplateVersion( + lastSuccessfulFetchResponse.templateVersion + ) ]); return true; } @@ -351,3 +360,30 @@ export async function setCustomSignals( ); } } + +// 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 RC 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 + */ +export function onConfigUpdate( + remoteConfig: RemoteConfig, + observer: ConfigUpdateObserver +): Unsubscribe { + const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; + rc._realtimeHandler.addObserver(observer); + return () => { + rc._realtimeHandler.removeObserver(observer); + }; +} diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts new file mode 100644 index 00000000000..10e2201ba2b --- /dev/null +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -0,0 +1,104 @@ +/** + * @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. + */ + +import { assert } from '@firebase/util'; + +// 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. + */ +export abstract class EventEmitter { + private listeners_: { + [eventType: string]: Array<{ + callback(...args: unknown[]): void; + context: unknown; + }>; + } = {}; + + constructor(private allowedEvents_: string[]) { + assert( + Array.isArray(allowedEvents_) && allowedEvents_.length > 0, + 'Requires a non-empty array' + ); + } + + /** + * To be overridden by derived classes in order to fire an initial event when + * somebody subscribes for data. + * + * @returns {Array.<*>} Array of parameters to trigger initial event with. + */ + abstract getInitialEvent(eventType: string): unknown[]; + + /** + * To be called by derived classes to trigger events. + */ + protected trigger(eventType: string, ...varArgs: unknown[]): void { + 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: string, + callback: (a: unknown) => void, + context: unknown + ): void { + 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: string, + callback: (a: unknown) => void, + context: unknown + ): void { + 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; + } + } + } + + private validateEventType_(eventType: string): void { + assert( + this.allowedEvents_.find(et => { + return et === eventType; + }), + 'Unknown event: ' + eventType + ); + } +} diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts new file mode 100644 index 00000000000..2ed244b5bd4 --- /dev/null +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -0,0 +1,715 @@ +/** + * @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. + */ + +import { _FirebaseInstallationsInternal } from '@firebase/installations'; +import { Logger } from '@firebase/logger'; +import { + ConfigUpdate, + ConfigUpdateObserver, + FetchResponse, + FirebaseRemoteConfigObject +} from '../public_types'; +import { calculateBackoffMillis, FirebaseError } from '@firebase/util'; +import { ERROR_FACTORY, ErrorCode } from '../errors'; +import { Storage } from '../storage/storage'; +import { VisibilityMonitor } from './visibility_monitor'; +import { StorageCache } from '../storage/storage_cache'; +import { + FetchRequest, + RemoteConfigAbortSignal +} from './remote_config_fetch_client'; +import { CachingClient } from './caching_client'; + +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'; + +export class RealtimeHandler { + constructor( + private readonly firebaseInstallations: _FirebaseInstallationsInternal, + private readonly storage: Storage, + private readonly sdkVersion: string, + private readonly namespace: string, + private readonly projectId: string, + private readonly apiKey: string, + private readonly appId: string, + private readonly logger: Logger, + private readonly storageCache: StorageCache, + private readonly cachingClient: CachingClient + ) { + void this.setRetriesRemaining(); + void VisibilityMonitor.getInstance().on( + 'visible', + this.onVisibilityChange, + this + ); + } + + private observers: Set = + new Set(); + private isConnectionActive: boolean = false; + private isRealtimeDisabled: boolean = false; + private controller?: AbortController; + private reader: ReadableStreamDefaultReader | undefined; + private httpRetriesRemaining: number = ORIGINAL_RETRIES; + private isInBackground: boolean = false; + private readonly decoder = new TextDecoder('utf-8'); + private isClosingConnection: boolean = false; + + private async setRetriesRemaining(): Promise { + // 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 + ); + } + + private propagateError = (e: FirebaseError): void => + this.observers.forEach(o => o.error?.(e)); + + /** + * 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. + */ + private async updateBackoffMetadataWithLastFailedStreamConnectionTime( + lastFailedStreamTime: Date + ): Promise { + const numFailedStreams = + ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || + 0) + 1; + const backoffMillis = 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. + */ + private async updateBackoffMetadataWithRetryInterval( + retryIntervalSeconds: number + ): Promise { + 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(); + } + + /** + * HTTP status code that the Realtime client should retry on. + */ + private isStatusCodeRetryable = (statusCode?: number): boolean => { + const retryableStatusCodes = [ + 408, // Request Timeout + 429, // Too Many Requests + 502, // Bad Gateway + 503, // Service Unavailable + 504 // Gateway Timeout + ]; + return !statusCode || retryableStatusCodes.includes(statusCode); + }; + + /** + * 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. + */ + private async closeRealtimeHttpConnection(): Promise { + 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; + } + + private async resetRealtimeBackoff(): Promise { + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + }); + } + + private resetRetryCount(): void { + 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. + */ + private async establishRealtimeConnection( + url: URL, + installationId: string, + installationTokenResult: string, + signal: AbortSignal + ): Promise { + 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; + } + + private getRealtimeUrl(): URL { + 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); + } + + private async createRealtimeConnection(): Promise { + 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. + */ + private async retryHttpConnectionWhenBackoffEnds(): Promise { + 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); + } + + private setIsHttpConnectionRunning(connectionRunning: boolean): void { + 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. + */ + private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { + const canMakeConnection = this.canEstablishStreamConnection(); + if (canMakeConnection) { + this.setIsHttpConnectionRunning(true); + } + return canMakeConnection; + } + + private fetchResponseIsUpToDate( + fetchResponse: FetchResponse, + lastKnownVersion: number + ): boolean { + // 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'; + } + + private parseAndValidateConfigUpdateMessage(message: string): string { + const left = message.indexOf('{'); + const right = message.indexOf('}', left); + + if (left < 0 || right < 0) { + return ''; + } + return left >= right ? '' : message.substring(left, right + 1); + } + + private isEventListenersEmpty(): boolean { + return this.observers.size === 0; + } + + private getRandomInt(max: number): number { + return Math.floor(Math.random() * max); + } + + private executeAllListenerCallbacks(configUpdate: ConfigUpdate): void { + 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. + */ + private getChangedParams( + newConfig: FirebaseRemoteConfigObject, + oldConfig: FirebaseRemoteConfigObject + ): Set { + 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; + } + + private async fetchLatestConfig( + remainingAttempts: number, + targetVersion: number + ): Promise { + 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: FetchRequest = { + cacheMaxAgeMillis: 0, + signal: abortSignal, + customSignals, + fetchType: 'REALTIME', + fetchAttempt: currentAttempt + }; + + const fetchResponse: 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: ConfigUpdate = { + getUpdatedKeys(): Set { + return new Set(updatedKeys); + } + }; + this.executeAllListenerCallbacks(configUpdate); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, { + originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}` + }); + this.propagateError(error); + } + } + + private async autoFetch( + remainingAttempts: number, + targetVersion: number + ): Promise { + if (remainingAttempts === 0) { + const error = ERROR_FACTORY.create(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. + */ + private async handleNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + let partialConfigUpdateMessage: string; + 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( + 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: unknown) { + 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(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID, { + originalErrorMessage: errorMessage + }) + ); + } + currentConfigUpdateMessage = ''; + } + } + } + + private async listenForNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + 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. + */ + private async prepareAndBeginRealtimeHttpStream(): Promise { + 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: Response | undefined; + let responseCode: number | undefined; + 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( + ErrorCode.CONFIG_UPDATE_STREAM_ERROR, + { + originalErrorMessage: errorMessage + } + ); + this.propagateError(firebaseError); + } + } + } + + /** + * Checks whether connection can be made or not based on some conditions + * @returns booelean + */ + private canEstablishStreamConnection(): boolean { + const hasActiveListeners = this.observers.size > 0; + const isNotDisabled = !this.isRealtimeDisabled; + const isNoConnectionActive = !this.isConnectionActive; + const inForeground = !this.isInBackground; + return ( + hasActiveListeners && + isNotDisabled && + isNoConnectionActive && + inForeground + ); + } + + private async makeRealtimeHttpConnection(delayMillis: number): Promise { + 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(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { + originalErrorMessage: + 'Unable to connect to the server. Check your connection and try again.' + }); + this.propagateError(error); + } + } + + private async beginRealtime(): Promise { + if (this.observers.size > 0) { + await this.makeRealtimeHttpConnection(0); + } + } + + /** + * Adds an observer to the realtime updates. + * @param observer The observer to add. + */ + addObserver(observer: ConfigUpdateObserver): void { + this.observers.add(observer); + void this.beginRealtime(); + } + + /** + * Removes an observer from the realtime updates. + * @param observer The observer to remove. + */ + removeObserver(observer: ConfigUpdateObserver): void { + 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. + */ + private async onVisibilityChange(visible: unknown): Promise { + this.isInBackground = !visible; + if (!visible) { + await this.closeRealtimeHttpConnection(); + } else if (visible) { + await this.beginRealtime(); + } + } +} diff --git a/packages/remote-config/src/client/remote_config_fetch_client.ts b/packages/remote-config/src/client/remote_config_fetch_client.ts index 359bb7c0409..ddc1ba4279c 100644 --- a/packages/remote-config/src/client/remote_config_fetch_client.ts +++ b/packages/remote-config/src/client/remote_config_fetch_client.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { CustomSignals, FetchResponse } from '../public_types'; +import { CustomSignals, FetchResponse, FetchType } from '../public_types'; /** * Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the @@ -100,4 +100,18 @@ export interface FetchRequest { *

Optional in case no custom signals are set for the instance. */ customSignals?: CustomSignals; + + /** + * The type of fetch to perform, such as a regular fetch or a real-time fetch. + * + * Optional as not all fetch requests need to be distinguished. + */ + fetchType?: FetchType; + + /** + * The number of fetch attempts made so far for this request. + * + * Optional as not all fetch requests are part of a retry series. + */ + fetchAttempt?: number; } diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 57f55f53d88..42b0cab27c6 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -88,6 +88,8 @@ export class RestClient implements RemoteConfigFetchClient { // 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: FetchRequestBody = { @@ -140,6 +142,7 @@ export class RestClient implements RemoteConfigFetchClient { let config: FirebaseRemoteConfigObject | undefined; let state: string | undefined; + let templateVersion: number | undefined; // 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. @@ -154,6 +157,7 @@ export class RestClient implements RemoteConfigFetchClient { } config = responseBody['entries']; state = responseBody['state']; + templateVersion = responseBody['templateVersion']; } // Normalizes based on legacy state. @@ -176,6 +180,6 @@ export class RestClient implements RemoteConfigFetchClient { }); } - return { status, eTag: responseEtag, config }; + return { status, eTag: responseEtag, config, templateVersion }; } } diff --git a/packages/remote-config/src/client/visibility_monitor.ts b/packages/remote-config/src/client/visibility_monitor.ts new file mode 100644 index 00000000000..27028e3eeca --- /dev/null +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -0,0 +1,86 @@ +/** + * @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. + */ + +import { assert } from '@firebase/util'; + +import { EventEmitter } from './eventEmitter'; + +declare const document: Document; + +// 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. +export class VisibilityMonitor extends EventEmitter { + private visible_: boolean; + + static getInstance(): VisibilityMonitor { + return new VisibilityMonitor(); + } + + constructor() { + super(['visible']); + let hidden: string; + let visibilityChange: string; + 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: string): boolean[] { + assert(eventType === 'visible', 'Unknown event type: ' + eventType); + return [this.visible_]; + } +} diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index 446bd2c6e7a..dea9f43e922 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -33,7 +33,11 @@ export const enum ErrorCode { FETCH_PARSE = 'fetch-client-parse', FETCH_STATUS = 'fetch-status', INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable', - CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals' + CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals', + CONFIG_UPDATE_STREAM_ERROR = 'stream-error', + CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable', + CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid', + CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -72,7 +76,15 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { [ErrorCode.INDEXED_DB_UNAVAILABLE]: 'Indexed DB is not supported by current browser', [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: - 'Setting more than {$maxSignals} custom signals is not supported.' + 'Setting more than {$maxSignals} custom signals is not supported.', + [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: + 'The stream was not able to connect to the backend: {$originalErrorMessage}.', + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: + 'The Realtime service is unavailable: {$originalErrorMessage}', + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: + 'The stream invalidation message was unparsable: {$originalErrorMessage}', + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: + 'Unable to fetch the latest config: {$originalErrorMessage}' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -92,6 +104,10 @@ interface ErrorParams { [ErrorCode.FETCH_PARSE]: { originalErrorMessage: string }; [ErrorCode.FETCH_STATUS]: { httpStatus: number }; [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: { maxSignals: number }; + [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index 927bc84ca10..964726a51f4 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app'; +import { FirebaseApp, FirebaseError } from '@firebase/app'; /** * The Firebase Remote Config service interface. @@ -52,6 +52,8 @@ export interface RemoteConfig { /** * Defines a self-descriptive reference for config key-value pairs. + * + * @public */ export interface FirebaseRemoteConfigObject { [key: string]: string; @@ -62,6 +64,8 @@ export interface FirebaseRemoteConfigObject { * *

Modeled after the native `Response` interface, but simplified for Remote Config's * use case. + * + * @public */ export interface FetchResponse { /** @@ -90,6 +94,11 @@ export interface FetchResponse { */ config?: FirebaseRemoteConfigObject; + /** + * The version number of the config template fetched from the server. + */ + templateVersion?: number; + // Note: we're not extracting experiment metadata until // ABT and Analytics have Web SDKs. } @@ -212,6 +221,63 @@ export interface CustomSignals { [key: string]: string | number | null; } +/** + * Contains information about which keys have been updated. + * + * @public + */ +export interface ConfigUpdate { + /** + * Parameter keys whose values have been updated from the currently activated values. + * Includes keys that are added, deleted, or whose value, value source, or metadata has changed. + */ + getUpdatedKeys(): Set; +} + +/** + * Observer interface for receiving real-time Remote Config update notifications. + * + * NOTE: Although an `complete` callback can be provided, it will + * never be called because the ConfigUpdate stream is never-ending. + * + * @public + */ +export interface ConfigUpdateObserver { + /** + * Called when a new ConfigUpdate is available. + */ + next: (configUpdate: ConfigUpdate) => void; + + /** + * Called if an error occurs during the stream. + */ + error: (error: FirebaseError) => void; + + /** + * Called when the stream is gracefully terminated. + */ + complete: () => void; +} + +/** + * A function that unsubscribes from a real-time event stream. + * + * @public + */ +export type Unsubscribe = () => void; + +/** + * Indicates the type of fetch request. + * + *

    + *
  • "BASE" indicates a standard fetch request.
  • + *
  • "REALTIME" indicates a fetch request triggered by a real-time update.
  • + *
+ * + * @public + */ +export type FetchType = 'BASE' | 'REALTIME'; + declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index 160e20219ce..eade371ca89 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -37,6 +37,7 @@ import { ErrorCode, ERROR_FACTORY } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; import { IndexedDbStorage, InMemoryStorage } from './storage/storage'; import { StorageCache } from './storage/storage_cache'; +import { RealtimeHandler } from './client/realtime_handler'; // This needs to be in the same file that calls `getProvider()` on the component // or it will get tree-shaken out. import '@firebase/installations'; @@ -107,12 +108,26 @@ export function registerRemoteConfig(): void { logger ); + const realtimeHandler = new RealtimeHandler( + installations, + storage, + SDK_VERSION, + namespace, + projectId, + apiKey, + appId, + logger, + storageCache, + cachingClient + ); + const remoteConfigInstance = new RemoteConfigImpl( app, cachingClient, storageCache, storage, - logger + logger, + realtimeHandler ); // Starts warming cache. diff --git a/packages/remote-config/src/remote_config.ts b/packages/remote-config/src/remote_config.ts index bd2db66d0b3..bd32c938304 100644 --- a/packages/remote-config/src/remote_config.ts +++ b/packages/remote-config/src/remote_config.ts @@ -25,6 +25,7 @@ import { StorageCache } from './storage/storage_cache'; import { RemoteConfigFetchClient } from './client/remote_config_fetch_client'; import { Storage } from './storage/storage'; import { Logger } from '@firebase/logger'; +import { RealtimeHandler } from './client/realtime_handler'; const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute const DEFAULT_CACHE_MAX_AGE_MILLIS = 12 * 60 * 60 * 1000; // Twelve hours. @@ -83,6 +84,10 @@ export class RemoteConfig implements RemoteConfigType { /** * @internal */ - readonly _logger: Logger + readonly _logger: Logger, + /** + * @internal + */ + readonly _realtimeHandler: RealtimeHandler ) {} } diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index f03ff41377b..bd262d29968 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -56,6 +56,13 @@ export interface ThrottleMetadata { throttleEndTimeMillis: number; } +export interface RealtimeBackoffMetadata { + // The number of consecutive connection streams that have failed. + numFailedStreams: number; + // The Date until which the client should wait before attempting any new real-time connections. + backoffEndTimeMillis: Date; +} + /** * Provides type-safety for the "key" field used by {@link APP_NAMESPACE_STORE}. * @@ -69,7 +76,9 @@ type ProjectNamespaceKeyFieldValue = | 'last_successful_fetch_response' | 'settings' | 'throttle_metadata' - | 'custom_signals'; + | 'custom_signals' + | 'realtime_backoff_metadata' + | 'last_known_template_version'; // Visible for testing. export function openDatabase(): Promise { @@ -178,6 +187,27 @@ export abstract class Storage { abstract get(key: ProjectNamespaceKeyFieldValue): Promise; abstract set(key: ProjectNamespaceKeyFieldValue, value: T): Promise; abstract delete(key: ProjectNamespaceKeyFieldValue): Promise; + + getRealtimeBackoffMetadata(): Promise { + return this.get('realtime_backoff_metadata'); + } + + setRealtimeBackoffMetadata( + realtimeMetadata: RealtimeBackoffMetadata + ): Promise { + return this.set( + 'realtime_backoff_metadata', + realtimeMetadata + ); + } + + getActiveConfigTemplateVersion(): Promise { + return this.get('last_known_template_version'); + } + + setActiveConfigTemplateVersion(version: number): Promise { + return this.set('last_known_template_version', version); + } } export class IndexedDbStorage extends Storage { diff --git a/packages/remote-config/test/api.test.ts b/packages/remote-config/test/api.test.ts index b1fe658ebae..f38b4ca0bee 100644 --- a/packages/remote-config/test/api.test.ts +++ b/packages/remote-config/test/api.test.ts @@ -17,11 +17,13 @@ import { expect } from 'chai'; import { + ConfigUpdateObserver, ensureInitialized, fetchAndActivate, FetchResponse, getRemoteConfig, - getString + getString, + onConfigUpdate } from '../src'; import '../test/setup'; import { @@ -34,6 +36,8 @@ import * as sinon from 'sinon'; import { Component, ComponentType } from '@firebase/component'; import { FirebaseInstallations } from '@firebase/installations-types'; import { openDatabase, APP_NAMESPACE_STORE } from '../src/storage/storage'; +import { ERROR_FACTORY, ErrorCode } from '../src/errors'; +import { RemoteConfig as RemoteConfigImpl } from '../src/remote_config'; const fakeFirebaseConfig = { apiKey: 'api-key', @@ -45,6 +49,12 @@ const fakeFirebaseConfig = { appId: '1:111:web:a1234' }; +const mockObserver = { + next: sinon.stub(), + error: sinon.stub(), + complete: sinon.stub() +}; + async function clearDatabase(): Promise { const db = await openDatabase(); db.transaction([APP_NAMESPACE_STORE], 'readwrite') @@ -57,7 +67,8 @@ describe('Remote Config API', () => { const STUB_FETCH_RESPONSE: FetchResponse = { status: 200, eTag: 'asdf', - config: { 'foobar': 'hello world' } + config: { 'foobar': 'hello world' }, + templateVersion: 1 }; let fetchStub: sinon.SinonStub; @@ -94,7 +105,8 @@ describe('Remote Config API', () => { json: () => Promise.resolve({ entries: response.config, - state: 'OK' + state: 'OK', + templateVersion: response.templateVersion }) } as Response) ); @@ -149,4 +161,99 @@ describe('Remote Config API', () => { await ensureInitialized(rc); expect(getString(rc, 'foobar')).to.equal('hello world'); }); + + describe('onConfigUpdate', () => { + let capturedObserver: ConfigUpdateObserver | undefined; + let rc: RemoteConfigImpl; + let addObserverStub: sinon.SinonStub; + let removeObserverStub: sinon.SinonStub; + + beforeEach(() => { + rc = getRemoteConfig(app) as RemoteConfigImpl; + + addObserverStub = sinon + .stub(rc._realtimeHandler, 'addObserver') + .resolves(); + removeObserverStub = sinon + .stub(rc._realtimeHandler, 'removeObserver') + .resolves(); + + addObserverStub.callsFake(async (observer: ConfigUpdateObserver) => { + capturedObserver = observer; + }); + }); + + afterEach(() => { + capturedObserver = undefined; + addObserverStub.restore(); + removeObserverStub.restore(); + }); + + it('should call addObserver on the internal realtimeHandler', async () => { + await onConfigUpdate(rc, mockObserver); + expect(addObserverStub).to.have.been.calledOnce; + expect(addObserverStub).to.have.been.calledWith(mockObserver); + }); + + it('should return an unsubscribe function', async () => { + const unsubscribe = await onConfigUpdate(rc, mockObserver); + expect(unsubscribe).to.be.a('function'); + }); + + it('returned unsubscribe function should call removeObserver', async () => { + const unsubscribe = await onConfigUpdate(rc, mockObserver); + + unsubscribe(); + expect(removeObserverStub).to.have.been.calledOnce; + expect(removeObserverStub).to.have.been.calledWith(mockObserver); + }); + + it('observer.next should be called when realtimeHandler propagates an update', async () => { + await onConfigUpdate(rc, mockObserver); + + if (capturedObserver && capturedObserver.next) { + const mockConfigUpdate = { getUpdatedKeys: () => new Set(['new_key']) }; + capturedObserver.next(mockConfigUpdate); + } else { + expect.fail('Observer was not captured or next method is missing.'); + } + + expect(mockObserver.next).to.have.been.calledOnce; + expect(mockObserver.next).to.have.been.calledWithMatch({ + getUpdatedKeys: sinon.match.func + }); + expect( + mockObserver.next.getCall(0).args[0].getUpdatedKeys() + ).to.deep.equal(new Set(['new_key'])); + }); + + it('observer.error should be called when realtimeHandler propagates an error', async () => { + await onConfigUpdate(rc, mockObserver); + + if (capturedObserver && capturedObserver.error) { + const expectedOriginalErrorMessage = 'Realtime stream error'; + const mockError = ERROR_FACTORY.create( + ErrorCode.CONFIG_UPDATE_STREAM_ERROR, + { + originalErrorMessage: expectedOriginalErrorMessage + } + ); + capturedObserver.error(mockError); + } else { + expect.fail('Observer was not captured or error method is missing.'); + } + + expect(mockObserver.error).to.have.been.calledOnce; + const receivedError = mockObserver.error.getCall(0).args[0]; + + expect(receivedError.message).to.equal( + 'Remote Config: The stream was not able to connect to the backend: Realtime stream error. (remoteconfig/stream-error).' + ); + expect(receivedError).to.have.nested.property( + 'customData.originalErrorMessage', + 'Realtime stream error' + ); + expect((receivedError as any).code).to.equal('remoteconfig/stream-error'); + }); + }); }); diff --git a/packages/remote-config/test/client/realtime_handler.test.ts b/packages/remote-config/test/client/realtime_handler.test.ts new file mode 100644 index 00000000000..fbdbe982b8b --- /dev/null +++ b/packages/remote-config/test/client/realtime_handler.test.ts @@ -0,0 +1,911 @@ +/** + * @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. + */ + +import { expect, use } from 'chai'; +import * as sinon from 'sinon'; +import sinonChai from 'sinon-chai'; +import { RealtimeHandler } from '../../src/client/realtime_handler'; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; +import { Logger } from '@firebase/logger'; +import { Storage } from '../../src/storage/storage'; +import { StorageCache } from '../../src/storage/storage_cache'; +import { CachingClient } from '../../src/client/caching_client'; +import { ConfigUpdateObserver, FetchResponse } from '../../src/public_types'; +import { ErrorCode } from '../../src/errors'; +import { VisibilityMonitor } from '../../src/client/visibility_monitor'; + +use(sinonChai); + +const FAKE_APP_ID = '1:123456789:web:abcdef'; +const INSTALLATION_ID_STRING = 'installation-id-123'; +const INSTALLATION_AUTH_TOKEN_STRING = 'installation-auth-token-456'; +const PROJECT_NUMBER = '123456789'; +const API_KEY = 'api-key-123'; +const FAKE_NOW = 1234567890; +const ORIGINAL_RETRIES = 8; +const MAXIMUM_FETCH_ATTEMPTS = 3; + +const DUMMY_FETCH_RESPONSE: FetchResponse = { + status: 200, + config: { testKey: 'test_value' }, + eTag: 'etag-2', + templateVersion: 2 +}; + +// Helper to create a mock ReadableStream from a string array. +function createMockReadableStream( + chunks: string[] = [] +): ReadableStream { + const encoder = new TextEncoder(); + return new ReadableStream({ + start(controller) { + for (const chunk of chunks) { + controller.enqueue(encoder.encode(chunk)); + } + controller.close(); + } + }); +} + +function createStreamingMockReader( + chunks: string[] +): ReadableStreamDefaultReader { + const stream = createMockReadableStream(chunks); + const reader = stream.getReader(); + const originalRead = reader.read; + sinon.stub(reader, 'read').callsFake(originalRead.bind(reader)); + return reader; +} + +describe('RealtimeHandler', () => { + let mockFetch: sinon.SinonStub; + let mockInstallations: sinon.SinonStubbedInstance<_FirebaseInstallationsInternal>; + let mockStorage: sinon.SinonStubbedInstance; + let mockStorageCache: sinon.SinonStubbedInstance; + let mockCachingClient: sinon.SinonStubbedInstance; + let mockLogger: sinon.SinonStubbedInstance; + let realtime: RealtimeHandler; + let clock: sinon.SinonFakeTimers; + let visibilityMonitorOnStub: sinon.SinonStub; + + beforeEach(async () => { + mockFetch = sinon.stub(window, 'fetch'); + mockInstallations = { + getId: sinon.stub().resolves(INSTALLATION_ID_STRING), + getToken: sinon.stub().resolves(INSTALLATION_AUTH_TOKEN_STRING) + } as any; + + mockLogger = sinon.createStubInstance(Logger); + + mockStorage = { + getRealtimeBackoffMetadata: sinon.stub().resolves(undefined), + setRealtimeBackoffMetadata: sinon.stub().resolves(), + getActiveConfigEtag: sinon.stub().resolves('etag-1'), + getActiveConfigTemplateVersion: sinon.stub().resolves(1), + getActiveConfig: sinon.stub().resolves({}), + + getLastFetchStatus: sinon.stub(), + setLastFetchStatus: sinon.stub(), + getLastSuccessfulFetchTimestampMillis: sinon.stub(), + setLastSuccessfulFetchTimestampMillis: sinon.stub(), + getLastSuccessfulFetchResponse: sinon.stub(), + setLastSuccessfulFetchResponse: sinon.stub(), + setActiveConfig: sinon.stub(), + setActiveConfigEtag: sinon.stub(), + getThrottleMetadata: sinon.stub(), + setThrottleMetadata: sinon.stub(), + deleteThrottleMetadata: sinon.stub(), + getCustomSignals: sinon.stub(), + setCustomSignals: sinon.stub(), + setActiveConfigTemplateVersion: sinon.stub() + } as sinon.SinonStubbedInstance; + + mockStorageCache = sinon.createStubInstance(StorageCache); + mockStorageCache.getLastFetchStatus.returns('success'); + mockStorageCache.getCustomSignals.returns(undefined); + + mockCachingClient = sinon.createStubInstance(CachingClient); + mockCachingClient.fetch.resolves(DUMMY_FETCH_RESPONSE); + + visibilityMonitorOnStub = sinon.stub(); + sinon.stub(VisibilityMonitor, 'getInstance').returns({ + on: visibilityMonitorOnStub + } as any); + + clock = sinon.useFakeTimers(FAKE_NOW); + + realtime = new RealtimeHandler( + mockInstallations, + mockStorage as any, + 'sdk-version', + 'namespace', + PROJECT_NUMBER, + API_KEY, + FAKE_APP_ID, + mockLogger as any, + mockStorageCache as any, + mockCachingClient as any + ); + }); + + afterEach(() => { + sinon.restore(); + clock.restore(); + }); + + describe('constructor', () => { + it('should initialize with default retries if no backoff metadata in storage', async () => { + await clock.runAllAsync(); + expect((realtime as any).httpRetriesRemaining).to.equal(ORIGINAL_RETRIES); + }); + + it('should set retries remaining from storage if available', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(FAKE_NOW - 1000), // In the past, so no backoff + numFailedStreams: 3 + }); + + realtime = new RealtimeHandler( + mockInstallations, + mockStorage as any, + 'sdk-version', + 'namespace', + PROJECT_NUMBER, + API_KEY, + FAKE_APP_ID, + mockLogger as any, + mockStorageCache as any, + mockCachingClient as any + ); + await clock.runAllAsync(); + expect((realtime as any).httpRetriesRemaining).to.equal( + ORIGINAL_RETRIES - 3 + ); + }); + }); + + describe('getRealtimeUrl', () => { + it('should construct the correct URL', () => { + const url = (realtime as any).getRealtimeUrl(); + expect(url.toString()).to.equal( + `https://firebaseremoteconfigrealtime.googleapis.com/v1/projects/${PROJECT_NUMBER}/namespaces/namespace:streamFetchInvalidations?key=${API_KEY}` + ); + }); + + it('should use the URL base from window if it exists', () => { + (window as any).FIREBASE_REMOTE_CONFIG_URL_BASE = + 'https://test.googleapis.com'; + const url = (realtime as any).getRealtimeUrl(); + expect(url.toString()).to.equal( + `https://test.googleapis.com/v1/projects/${PROJECT_NUMBER}/namespaces/namespace:streamFetchInvalidations?key=${API_KEY}` + ); + delete (window as any).FIREBASE_REMOTE_CONFIG_URL_BASE; + }); + }); + + describe('isStatusCodeRetryable', () => { + it('should return true for retryable status codes', () => { + const retryableCodes = [408, 429, 502, 503, 504]; + retryableCodes.forEach(code => { + expect((realtime as any).isStatusCodeRetryable(code)).to.be.true; + }); + }); + + it('should return true for undefined status code', () => { + expect((realtime as any).isStatusCodeRetryable(undefined)).to.be.true; + }); + + it('should return false for non-retryable status codes', () => { + // This is a sample of non-retryable codes for testing purposes. + const nonRetryableCodes = [200, 304, 400, 401, 403]; + nonRetryableCodes.forEach(code => { + expect((realtime as any).isStatusCodeRetryable(code)).to.be.false; + }); + }); + }); + + describe('updateBackoffMetadataWithLastFailedStreamConnectionTime', () => { + it('should increment numFailedStreams and set backoffEndTimeMillis', async () => { + const spy = mockStorage.setRealtimeBackoffMetadata; + const lastFailedTime = new Date(FAKE_NOW); + + await ( + realtime as any + ).updateBackoffMetadataWithLastFailedStreamConnectionTime(lastFailedTime); + + expect(spy).to.have.been.calledOnce; + const metadata = spy.getCall(0).args[0]; + expect(metadata.numFailedStreams).to.equal(1); + expect(metadata.backoffEndTimeMillis.getTime()).to.be.greaterThan( + lastFailedTime.getTime() + ); + }); + }); + + describe('updateBackoffMetadataWithRetryInterval', () => { + it('should set backoffEndTimeMillis based on provided retryIntervalSeconds and then retry connection', async () => { + const setMetadataSpy = mockStorage.setRealtimeBackoffMetadata; + const retryHttpConnectionSpy = sinon.spy( + realtime as any, + 'retryHttpConnectionWhenBackoffEnds' + ); + const retryInterval = 10; + + await (realtime as any).updateBackoffMetadataWithRetryInterval( + retryInterval + ); + + expect(setMetadataSpy).to.have.been.calledOnce; + const metadata = setMetadataSpy.getCall(0).args[0]; + expect(metadata.backoffEndTimeMillis.getTime()).to.be.closeTo( + FAKE_NOW + retryInterval * 1000, + 100 + ); + expect(retryHttpConnectionSpy).to.have.been.calledOnce; + }); + }); + + describe('closeRealtimeHttpConnection', () => { + let mockController: sinon.SinonStubbedInstance; + let mockReader: sinon.SinonStubbedInstance< + ReadableStreamDefaultReader + >; + + beforeEach(() => { + mockController = sinon.createStubInstance(AbortController); + mockReader = sinon.createStubInstance(ReadableStreamDefaultReader); + (realtime as any).controller = mockController; + (realtime as any).reader = mockReader; + }); + + it('should abort controller and cancel reader', async () => { + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockController.abort).to.have.been.calledOnce; + expect(mockReader.cancel).to.have.been.calledOnce; + expect((realtime as any).controller).to.be.undefined; + expect((realtime as any).reader).to.be.undefined; + }); + + it('should handle reader cancellation failure gracefully', async () => { + mockReader.cancel.rejects(new Error('test error')); + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockLogger.debug).to.have.been.calledWith( + 'Failed to cancel the reader, connection was lost.' + ); + // Should still clear reader + expect((realtime as any).reader).to.be.undefined; + }); + + it('should handle being called when reader is already undefined', async () => { + (realtime as any).reader = undefined; + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockController.abort).to.have.been.calledOnce; + expect((realtime as any).controller).to.be.undefined; + }); + + it('should handle being called when controller is already undefined', async () => { + (realtime as any).controller = undefined; + await (realtime as any).closeRealtimeHttpConnection(); + expect(mockReader.cancel).to.have.been.calledOnce; + expect((realtime as any).reader).to.be.undefined; + }); + }); + + describe('resetRealtimeBackoff', () => { + it('should reset backoff metadata in storage', async () => { + const spy = mockStorage.setRealtimeBackoffMetadata; + await (realtime as any).resetRealtimeBackoff(); + expect(spy).to.have.been.calledOnce; + const metadata = spy.getCall(0).args[0]; + expect(metadata.numFailedStreams).to.equal(0); + expect(metadata.backoffEndTimeMillis.getTime()).to.equal(-1); + }); + }); + + describe('establishRealtimeConnection', () => { + it('should send correct headers and body for realtime connection', async () => { + mockStorage.getActiveConfigEtag.resolves('current-etag'); + mockStorage.getActiveConfigTemplateVersion.resolves(10); + + const url = new URL('https://example.com/stream'); + const signal = new AbortController().signal; + + await (realtime as any).establishRealtimeConnection( + url, + INSTALLATION_ID_STRING, + INSTALLATION_AUTH_TOKEN_STRING, + signal + ); + + expect(mockFetch).to.have.been.calledOnce; + const [fetchUrl, fetchOptions] = mockFetch.getCall(0).args; + expect(fetchUrl).to.equal(url); + expect(fetchOptions.method).to.equal('POST'); + expect(fetchOptions.headers).to.deep.include({ + 'X-Goog-Api-Key': API_KEY, + 'X-Goog-Firebase-Installations-Auth': INSTALLATION_AUTH_TOKEN_STRING, + 'Content-Type': 'application/json', + Accept: 'application/json', + 'If-None-Match': 'current-etag', + 'Content-Encoding': 'gzip' + }); + const body = JSON.parse(fetchOptions.body as string); + expect(body).to.deep.equal({ + project: PROJECT_NUMBER, + namespace: 'namespace', + lastKnownVersionNumber: 10, + appId: FAKE_APP_ID, + sdkVersion: 'sdk-version', + appInstanceId: INSTALLATION_ID_STRING + }); + }); + }); + + describe('retryHttpConnectionWhenBackoffEnds', () => { + let makeRealtimeHttpConnectionSpy: sinon.SinonSpy; + + beforeEach(() => { + makeRealtimeHttpConnectionSpy = sinon.spy( + realtime as any, + 'makeRealtimeHttpConnection' + ); + }); + + it('should call makeRealtimeHttpConnection with 0 delay if no backoff metadata', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves(undefined); + await (realtime as any).retryHttpConnectionWhenBackoffEnds(); + expect(makeRealtimeHttpConnectionSpy).to.have.been.calledWith(0); + }); + + it('should call makeRealtimeHttpConnection with calculated delay if backoff metadata exists', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + // 5 seconds in the future + backoffEndTimeMillis: new Date(FAKE_NOW + 5000), + numFailedStreams: 1 + }); + await (realtime as any).retryHttpConnectionWhenBackoffEnds(); + expect(makeRealtimeHttpConnectionSpy).to.have.been.calledOnce; + const delay = makeRealtimeHttpConnectionSpy.getCall(0).args[0]; + expect(delay).to.be.closeTo(5000, 100); + }); + }); + + describe('fetchResponseIsUpToDate', () => { + it('should return true if templateVersion is greater or equal', () => { + const fetchResponse: FetchResponse = { + config: { k: 'v' }, + templateVersion: 5, + status: 200, + eTag: 'e' + }; + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.true; + }); + + it('should return false if templateVersion is smaller', () => { + const fetchResponse: FetchResponse = { + config: { k: 'v' }, + templateVersion: 4, + status: 200, + eTag: 'e' + }; + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.false; + }); + + it('should return true if no config and lastFetchStatus is success', () => { + const fetchResponse: FetchResponse = { + config: undefined, + templateVersion: undefined, + status: 304, + eTag: 'e' + }; + mockStorageCache.getLastFetchStatus.returns('success'); + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.true; + }); + + it('should return false if no config and lastFetchStatus is not success', () => { + const fetchResponse: FetchResponse = { + config: undefined, + templateVersion: undefined, + status: 304, + eTag: 'e' + }; + mockStorageCache.getLastFetchStatus.returns('throttle'); // Or any other non-'success' status + const result = (realtime as any).fetchResponseIsUpToDate( + fetchResponse, + 5 + ); + expect(result).to.be.false; + }); + }); + + describe('fetchLatestConfig', () => { + let autoFetchSpy: sinon.SinonSpy; + let executeAllListenerCallbacksSpy: sinon.SinonSpy; + + beforeEach(() => { + autoFetchSpy = sinon.spy(realtime as any, 'autoFetch'); + executeAllListenerCallbacksSpy = sinon.spy( + realtime as any, + 'executeAllListenerCallbacks' + ); + mockStorage.getActiveConfig.resolves({ existingKey: 'value' }); + mockStorage.getActiveConfigTemplateVersion.resolves(1); + }); + + afterEach(() => { + autoFetchSpy.restore(); + executeAllListenerCallbacksSpy.restore(); + }); + + it('should fetch, identify changed keys, and notify observers', async () => { + mockCachingClient.fetch.resolves({ + config: { existingKey: 'new_value', newKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(mockCachingClient.fetch).to.have.been.calledOnce; + expect(executeAllListenerCallbacksSpy).to.have.been.calledOnce; + const configUpdate = executeAllListenerCallbacksSpy.getCall(0).args[0]; + expect(configUpdate.getUpdatedKeys()).to.deep.equal( + new Set(['existingKey', 'newKey']) + ); + }); + + it('should retry with autoFetch if fetched version is not up-to-date', async () => { + autoFetchSpy.restore(); + const autoFetchStub = sinon.stub(realtime as any, 'autoFetch'); + + mockCachingClient.fetch.resolves({ + config: { k: 'v' }, + templateVersion: 1, + status: 200, + eTag: 'e' + }); + mockStorage.getActiveConfigTemplateVersion.resolves(0); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(mockCachingClient.fetch).to.have.been.calledOnce; + expect(autoFetchStub).to.have.been.calledOnceWith( + MAXIMUM_FETCH_ATTEMPTS - 1, + 2 + ); + }); + + it('should not notify if no keys have changed', async () => { + mockCachingClient.fetch.resolves({ + config: { existingKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(executeAllListenerCallbacksSpy).not.to.have.been.called; + }); + + it('should propagate error on fetch failure', async () => { + const testError = new Error('Network failed'); + mockCachingClient.fetch.rejects(testError); + const propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_NOT_FETCHED); + }); + + it('should include custom signals in fetch request', async () => { + mockStorageCache.getCustomSignals.returns({ signal1: 'value1' }); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + expect(mockLogger.debug).to.have.been.calledWith( + `Fetching config with custom signals: {"signal1":"value1"}` + ); + }); + + it('should handle null activatedConfigs gracefully', async () => { + mockCachingClient.fetch.resolves({ + config: { newKey: 'value' }, + templateVersion: 2, + status: 200, + eTag: 'e' + }); + mockStorage.getActiveConfig.resolves(null as any); + + await (realtime as any).fetchLatestConfig(MAXIMUM_FETCH_ATTEMPTS, 2); + + expect(executeAllListenerCallbacksSpy).to.have.been.calledOnce; + const configUpdate = executeAllListenerCallbacksSpy.getCall(0).args[0]; + expect(configUpdate.getUpdatedKeys()).to.deep.equal(new Set(['newKey'])); + }); + }); + + describe('autoFetch', () => { + let fetchLatestConfigStub: sinon.SinonStub; + let propagateErrorSpy: sinon.SinonSpy; + + beforeEach(() => { + fetchLatestConfigStub = sinon.stub(realtime as any, 'fetchLatestConfig'); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + }); + + afterEach(() => { + fetchLatestConfigStub.restore(); + propagateErrorSpy.restore(); + }); + + it('should call fetchLatestConfig after a random delay', async () => { + (realtime as any).autoFetch(MAXIMUM_FETCH_ATTEMPTS, 10); + await clock.runAllAsync(); + + expect(fetchLatestConfigStub).to.have.been.calledOnceWith( + MAXIMUM_FETCH_ATTEMPTS, + 10 + ); + }); + + it('should propagate an error if remaining attempts is zero', async () => { + await (realtime as any).autoFetch(0, 10); + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_NOT_FETCHED); + expect(fetchLatestConfigStub).not.to.have.been.called; + }); + }); + + describe('handleNotifications', () => { + let mockReader: ReadableStreamDefaultReader; + let autoFetchSpy: sinon.SinonSpy; + let executeAllListenerCallbacksSpy: sinon.SinonSpy; + let propagateErrorSpy: sinon.SinonSpy; + + beforeEach(() => { + autoFetchSpy = sinon.spy(realtime as any, 'autoFetch'); + executeAllListenerCallbacksSpy = sinon.spy( + realtime as any, + 'executeAllListenerCallbacks' + ); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + (realtime as any).observers.add({}); + }); + + afterEach(() => { + autoFetchSpy.restore(); + executeAllListenerCallbacksSpy.restore(); + propagateErrorSpy.restore(); + }); + + it('should set backoff metadata if REALTIME_RETRY_INTERVAL is present', async () => { + const updateBackoffStub = sinon + .stub(realtime as any, 'updateBackoffMetadataWithRetryInterval') + .resolves(); + + mockReader = createStreamingMockReader(['{"retryIntervalSeconds": 60}']); + + await (realtime as any).handleNotifications(mockReader); + + expect(updateBackoffStub).to.have.been.calledOnceWith(60); + }); + + it('should propagate error on invalid JSON', async () => { + mockReader = createStreamingMockReader(['{invalid_json}']); + + await (realtime as any).handleNotifications(mockReader); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID); + }); + + it('should break if event listeners become empty during handling', async () => { + autoFetchSpy.restore(); + + mockReader = createStreamingMockReader([ + '{"latestTemplateVersionNumber": 10}' + ]); + mockStorage.getActiveConfigTemplateVersion.resolves(5); + mockCachingClient.fetch.resolves({ + config: { k: 'v' }, + templateVersion: 10, + status: 200, + eTag: 'e' + }); + + const observer = (realtime as any).observers.values().next().value; + const originalJsonParse = JSON.parse; + JSON.parse = (text: string) => { + (realtime as any).observers.delete(observer); + return originalJsonParse(text); + }; + + await (realtime as any).handleNotifications(mockReader); + + expect(mockReader.read).to.have.been.calledOnce; + + JSON.parse = originalJsonParse; + }); + }); + + describe('beginRealtimeHttpStream', () => { + let createRealtimeConnectionSpy: sinon.SinonStub; + let listenForNotificationsSpy: sinon.SinonSpy; + let closeRealtimeHttpConnectionSpy: sinon.SinonSpy; + let retryHttpConnectionWhenBackoffEndsSpy: sinon.SinonStub; + let updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy: sinon.SinonSpy; + let propagateErrorSpy: sinon.SinonSpy; + let checkAndSetHttpConnectionFlagIfNotRunningSpy: sinon.SinonStub; + + beforeEach(() => { + createRealtimeConnectionSpy = sinon.stub( + realtime as any, + 'createRealtimeConnection' + ); + listenForNotificationsSpy = sinon.spy( + realtime as any, + 'listenForNotifications' + ); + closeRealtimeHttpConnectionSpy = sinon.spy( + realtime as any, + 'closeRealtimeHttpConnection' + ); + + retryHttpConnectionWhenBackoffEndsSpy = sinon + .stub(realtime as any, 'retryHttpConnectionWhenBackoffEnds') + .resolves(); + updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy = sinon.spy( + realtime as any, + 'updateBackoffMetadataWithLastFailedStreamConnectionTime' + ); + propagateErrorSpy = sinon.spy(realtime as any, 'propagateError'); + checkAndSetHttpConnectionFlagIfNotRunningSpy = sinon + .stub(realtime as any, 'checkAndSetHttpConnectionFlagIfNotRunning') + .returns(true); + + createRealtimeConnectionSpy.resolves( + new Response(createMockReadableStream(), { status: 200 }) + ); + + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + }); + (realtime as any).httpRetriesRemaining = ORIGINAL_RETRIES; + }); + + afterEach(() => { + retryHttpConnectionWhenBackoffEndsSpy.restore(); + }); + + it('should successfully establish and handle a connection', async () => { + const resetRealtimeBackoffSpy = sinon.spy( + realtime as any, + 'resetRealtimeBackoff' + ); + (realtime as any).observers.add({}); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(createRealtimeConnectionSpy).to.have.been.calledOnce; + expect(listenForNotificationsSpy).to.have.been.calledOnce; + expect(resetRealtimeBackoffSpy).to.have.been.calledOnce; + expect(closeRealtimeHttpConnectionSpy).to.have.been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + + it('should return early if connection flag cannot be set', async () => { + checkAndSetHttpConnectionFlagIfNotRunningSpy.returns(false); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + expect(createRealtimeConnectionSpy).not.to.have.been.called; + }); + + it('should retry if currently in backoff period', async () => { + mockStorage.getRealtimeBackoffMetadata.resolves({ + backoffEndTimeMillis: new Date(FAKE_NOW + 1000), + numFailedStreams: 1 + }); + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + expect(createRealtimeConnectionSpy).not.to.have.been.called; + }); + + it('should update backoff metadata on connection failure in foreground', async () => { + (realtime as any).httpRetriesRemaining = 1; + + createRealtimeConnectionSpy.resolves(new Response(null, { status: 502 })); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).to.have + .been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + + it('should NOT schedule a retry on connection failure in background', async () => { + (realtime as any).isInBackground = true; + + (realtime as any).observers.add({}); + + createRealtimeConnectionSpy.resolves(new Response(null, { status: 503 })); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).not.to + .have.been.called; + + expect(retryHttpConnectionWhenBackoffEndsSpy).not.to.have.been.called; + }); + + it('should propagate CONFIG_UPDATE_STREAM_ERROR if connection fails non-retryably', async () => { + (realtime as any).httpRetriesRemaining = 1; + createRealtimeConnectionSpy.resolves(new Response(null, { status: 400 })); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(retryHttpConnectionWhenBackoffEndsSpy).not.to.have.been.called; + expect(propagateErrorSpy).to.have.been.calledOnce; + }); + + it('should not propagate error if connection fails non-retryably in background', async () => { + (realtime as any).httpRetriesRemaining = 1; + createRealtimeConnectionSpy.resolves(new Response(null, { status: 400 })); + (realtime as any).observers.add({}); + (realtime as any).isInBackground = true; + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(propagateErrorSpy).to.have.been.calledOnce; + }); + + it('should propagate CONFIG_UPDATE_STREAM_ERROR if retries are exhausted', async () => { + (realtime as any).httpRetriesRemaining = 0; + (realtime as any).observers.add({}); + await (realtime as any).makeRealtimeHttpConnection(0); + + expect(propagateErrorSpy).to.have.been.calledOnce; + const error = propagateErrorSpy.getCall(0).args[0]; + expect(error.code).to.include(ErrorCode.CONFIG_UPDATE_STREAM_ERROR); + }); + + it('should handle rejection from createRealtimeConnection', async () => { + const testError = new Error('Connection refused'); + createRealtimeConnectionSpy.rejects(testError); + (realtime as any).observers.add({}); + + await (realtime as any).prepareAndBeginRealtimeHttpStream(); + + expect(updateBackoffMetadataWithLastFailedStreamConnectionTimeSpy).to.have + .been.calledOnce; + expect(retryHttpConnectionWhenBackoffEndsSpy).to.have.been.calledOnce; + }); + }); + + describe('canEstablishStreamConnection', () => { + it('returns true if all conditions are met', () => { + (realtime as any).observers.add({}); + (realtime as any).isRealtimeDisabled = false; + (realtime as any).isConnectionActive = false; + (realtime as any).isInBackground = false; + expect((realtime as any).canEstablishStreamConnection()).to.be.true; + }); + + it('returns false if there are no observers', () => { + (realtime as any).observers.clear(); + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if realtime is disabled', () => { + (realtime as any).observers.add({}); + (realtime as any).isRealtimeDisabled = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if a connection is already active', () => { + (realtime as any).observers.add({}); + (realtime as any).isConnectionActive = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + + it('returns false if app is in background', () => { + (realtime as any).observers.add({}); + (realtime as any).isInBackground = true; + expect((realtime as any).canEstablishStreamConnection()).to.be.false; + }); + }); + + describe('addObserver/removeObserver', () => { + let beginRealtimeStub: sinon.SinonStub; + const observer: ConfigUpdateObserver = { + next: () => {}, + error: () => {}, + complete: () => {} + }; + + beforeEach(() => { + beginRealtimeStub = sinon + .stub(realtime as any, 'beginRealtime') + .resolves(); + }); + + afterEach(() => { + beginRealtimeStub.restore(); + }); + + it('addObserver should add an observer and start the realtime connection', async () => { + await realtime.addObserver(observer); + expect((realtime as any).observers.has(observer)).to.be.true; + + expect(beginRealtimeStub).to.have.been.calledOnce; + }); + + it('removeObserver should remove an observer', () => { + (realtime as any).observers.add(observer); + realtime.removeObserver(observer); + expect((realtime as any).observers.has(observer)).to.be.false; + }); + }); + describe('onVisibilityChange', () => { + let closeConnectionSpy: sinon.SinonSpy; + let beginRealtimeSpy: sinon.SinonSpy; + + beforeEach(() => { + closeConnectionSpy = sinon.spy( + realtime as any, + 'closeRealtimeHttpConnection' + ); + beginRealtimeSpy = sinon.spy(realtime as any, 'beginRealtime'); + }); + + afterEach(() => { + closeConnectionSpy.restore(); + beginRealtimeSpy.restore(); + }); + + it('should close connection when app goes to background', async () => { + await (realtime as any).onVisibilityChange(false); + expect((realtime as any).isInBackground).to.be.true; + expect(closeConnectionSpy).to.have.been.calledOnce; + expect(beginRealtimeSpy).not.to.have.been.called; + }); + + it('should start connection when app comes to foreground', async () => { + await (realtime as any).onVisibilityChange(true); + expect((realtime as any).isInBackground).to.be.false; + expect(closeConnectionSpy).not.to.have.been.called; + expect(beginRealtimeSpy).to.have.been.calledOnce; + }); + }); +}); diff --git a/packages/remote-config/test/client/rest_client.test.ts b/packages/remote-config/test/client/rest_client.test.ts index 96a6cde8454..bda6fbce01a 100644 --- a/packages/remote-config/test/client/rest_client.test.ts +++ b/packages/remote-config/test/client/rest_client.test.ts @@ -26,6 +26,7 @@ import { FetchRequest, RemoteConfigAbortSignal } from '../../src/client/remote_config_fetch_client'; +import { Storage } from '../../src/storage/storage'; const DEFAULT_REQUEST: FetchRequest = { cacheMaxAgeMillis: 1, @@ -34,6 +35,7 @@ const DEFAULT_REQUEST: FetchRequest = { describe('RestClient', () => { const firebaseInstallations = {} as FirebaseInstallations; + const storage = {} as Storage; let client: RestClient; beforeEach(() => { @@ -51,6 +53,7 @@ describe('RestClient', () => { firebaseInstallations.getToken = sinon .stub() .returns(Promise.resolve('fis-token')); + storage.setActiveConfigTemplateVersion = sinon.stub(); }); describe('fetch', () => { @@ -74,7 +77,8 @@ describe('RestClient', () => { status: 200, eTag: 'etag', state: 'UPDATE', - entries: { color: 'sparkling' } + entries: { color: 'sparkling' }, + templateVersion: 1 }; fetchStub.returns( @@ -85,7 +89,8 @@ describe('RestClient', () => { json: () => Promise.resolve({ entries: expectedResponse.entries, - state: expectedResponse.state + state: expectedResponse.state, + templateVersion: expectedResponse.templateVersion }) } as Response) ); @@ -95,7 +100,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: expectedResponse.status, eTag: expectedResponse.eTag, - config: expectedResponse.entries + config: expectedResponse.entries, + templateVersion: expectedResponse.templateVersion }); }); @@ -184,7 +190,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'response-etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -222,7 +229,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -239,7 +247,8 @@ describe('RestClient', () => { await expect(client.fetch(DEFAULT_REQUEST)).to.eventually.be.deep.eq({ status: 200, eTag: 'etag', - config: {} + config: {}, + templateVersion: undefined }); } }); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 8010f54f26d..1cc6b62717e 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -46,6 +46,7 @@ import { import * as api from '../src/api'; import { fetchAndActivate } from '../src'; import { restore } from 'sinon'; +import { RealtimeHandler } from '../src/client/realtime_handler'; describe('RemoteConfig', () => { const ACTIVE_CONFIG = { @@ -67,6 +68,7 @@ describe('RemoteConfig', () => { let storageCache: StorageCache; let storage: Storage; let logger: Logger; + let realtimeHandler: RealtimeHandler; let rc: RemoteConfigType; let getActiveConfigStub: sinon.SinonStub; @@ -79,12 +81,20 @@ describe('RemoteConfig', () => { client = {} as RemoteConfigFetchClient; storageCache = {} as StorageCache; storage = {} as Storage; + realtimeHandler = {} as RealtimeHandler; logger = new Logger('package-name'); getActiveConfigStub = sinon.stub().returns(undefined); storageCache.getActiveConfig = getActiveConfigStub; loggerDebugSpy = sinon.spy(logger, 'debug'); loggerLogLevelSpy = sinon.spy(logger, 'logLevel', ['set']); - rc = new RemoteConfig(app, client, storageCache, storage, logger); + rc = new RemoteConfig( + app, + client, + storageCache, + storage, + logger, + realtimeHandler + ); }); afterEach(() => { @@ -380,39 +390,56 @@ describe('RemoteConfig', () => { const ETAG = 'etag'; const CONFIG = { key: 'val' }; const NEW_ETAG = 'new_etag'; + const TEMPLATE_VERSION = 1; let getLastSuccessfulFetchResponseStub: sinon.SinonStub; let getActiveConfigEtagStub: sinon.SinonStub; + let getActiveConfigTemplateVersionStub: sinon.SinonStub; let setActiveConfigEtagStub: sinon.SinonStub; let setActiveConfigStub: sinon.SinonStub; + let setActiveConfigTemplateVersionStub: sinon.SinonStub; beforeEach(() => { getLastSuccessfulFetchResponseStub = sinon.stub(); getActiveConfigEtagStub = sinon.stub(); + getActiveConfigTemplateVersionStub = sinon.stub(); setActiveConfigEtagStub = sinon.stub(); setActiveConfigStub = sinon.stub(); + setActiveConfigTemplateVersionStub = sinon.stub(); storage.getLastSuccessfulFetchResponse = getLastSuccessfulFetchResponseStub; storage.getActiveConfigEtag = getActiveConfigEtagStub; + storage.getActiveConfigTemplateVersion = + getActiveConfigTemplateVersionStub; storage.setActiveConfigEtag = setActiveConfigEtagStub; storageCache.setActiveConfig = setActiveConfigStub; + storage.setActiveConfigTemplateVersion = + setActiveConfigTemplateVersionStub; }); it('does not activate if last successful fetch response is undefined', async () => { getLastSuccessfulFetchResponseStub.returns(Promise.resolve()); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); + getActiveConfigTemplateVersionStub.returns( + Promise.resolve(TEMPLATE_VERSION) + ); const activateResponse = await activate(rc); expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; }); it('does not activate if fetched and active etags are the same', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: {}, etag: ETAG }) + Promise.resolve({ + config: {}, + eTag: ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -421,11 +448,16 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect(storage.setActiveConfigTemplateVersion).to.not.have.been.called; }); it('activates if fetched and active etags are different', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -434,11 +466,18 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); it('activates if fetched is defined but active config is not', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve()); @@ -447,6 +486,9 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); });