Remote Config Realtime for Web (#9221)

This commit is contained in:
Tushar Khandelwal
2025-09-12 10:13:55 -07:00
committed by GitHub
parent 0e9b34fbb4
commit 12914fd6d7
22 changed files with 2378 additions and 23 deletions
+7
View File
@@ -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.
+23
View File
@@ -5,10 +5,23 @@
```ts
import { FirebaseApp } from '@firebase/app';
import { FirebaseError } from '@firebase/app';
// @public
export function activate(remoteConfig: RemoteConfig): Promise<boolean>;
// @public
export interface ConfigUpdate {
getUpdatedKeys(): Set<string>;
}
// @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<boolean>;
// @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;
+4
View File
@@ -631,6 +631,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
@@ -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.
<b>Signature:</b>
```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.
<b>Signature:</b>
```typescript
getUpdatedKeys(): Set<string>;
```
<b>Returns:</b>
Set&lt;string&gt;
@@ -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.
<b>Signature:</b>
```typescript
export interface ConfigUpdateObserver
```
## Properties
| Property | Type | Description |
| --- | --- | --- |
| [complete](./remote-config.configupdateobserver.md#configupdateobservercomplete) | () =&gt; void | Called when the stream is gracefully terminated. |
| [error](./remote-config.configupdateobserver.md#configupdateobservererror) | (error: [FirebaseError](./util.firebaseerror.md#firebaseerror_class)<!-- -->) =&gt; void | Called if an error occurs during the stream. |
| [next](./remote-config.configupdateobserver.md#configupdateobservernext) | (configUpdate: [ConfigUpdate](./remote-config.configupdate.md#configupdate_interface)<!-- -->) =&gt; void | Called when a new ConfigUpdate is available. |
## ConfigUpdateObserver.complete
Called when the stream is gracefully terminated.
<b>Signature:</b>
```typescript
complete: () => void;
```
## ConfigUpdateObserver.error
Called if an error occurs during the stream.
<b>Signature:</b>
```typescript
error: (error: FirebaseError) => void;
```
## ConfigUpdateObserver.next
Called when a new ConfigUpdate is available.
<b>Signature:</b>
```typescript
next: (configUpdate: ConfigUpdate) => void;
```
@@ -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.<p>Only defined for 200 responses. |
| [eTag](./remote-config.fetchresponse.md#fetchresponseetag) | string | Defines the ETag response header value.<p>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.<p>The Remote Config client is modeled after the native <code>Fetch</code> interface, so HTTP status is first-class.<p>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.
<b>Signature:</b>
```typescript
templateVersion?: number;
```
+52
View File
@@ -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 <code>remoteConfig.getValue(key).asNumber()</code>. |
| [getString(remoteConfig, key)](./remote-config.md#getstring_476c09f) | Gets the value for the given key as a string. Convenience method for calling <code>remoteConfig.getValue(key).asString()</code>. |
| [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 Remote Config 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. |
| <b>function()</b> |
@@ -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 <code>complete</code> 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.<p>The values in CustomSignals must be one of the following types:<ul> <li><code>string</code> <li><code>number</code> <li><code>null</code> </ul> |
| [FetchResponse](./remote-config.fetchresponse.md#fetchresponse_interface) | Defines a successful response (200 or 304).<p>Modeled after the native <code>Response</code> 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.<ul> <li>"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.</li> <li>"success" indicates the last attempt succeeded.</li> <li>"failure" indicates the last attempt failed.</li> <li>"throttle" indicates the last attempt was rate-limited.</li> </ul> |
| [FetchType](./remote-config.md#fetchtype) | Indicates the type of fetch request.<ul> <li>"BASE" indicates a standard fetch request.</li> <li>"REALTIME" indicates a fetch request triggered by a real-time update.</li> </ul> |
| [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.<ul> <li>"static" indicates the value was defined by a static constant.</li> <li>"default" indicates the value was defined by default config.</li> <li>"remote" indicates the value was defined by fetched config.</li> </ul> |
## 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 Remote Config 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.
<b>Signature:</b>
```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. |
<b>Returns:</b>
[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.
<ul> <li>"BASE" indicates a standard fetch request.</li> <li>"REALTIME" indicates a fetch request triggered by a real-time update.</li> </ul>
<b>Signature:</b>
```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.
<b>Signature:</b>
```typescript
export type Unsubscribe = () => void;
```
## ValueSource
Indicates the source of a value.
+38 -2
View File
@@ -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<boolean> {
!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<boolean> {
}
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 Remote Config backend when they are available.
*
* @remarks
* If a connection to the Remote Config backend is not already open, calling this method will
* open it. Multiple listeners can be added by calling this method again, but subsequent calls
* re-use the same connection to the backend.
*
* @param remoteConfig - The {@link RemoteConfig} instance.
* @param observer - The {@link ConfigUpdateObserver} to be notified of config updates.
* @returns An {@link Unsubscribe} function to remove the listener.
*
* @public
*/
export function onConfigUpdate(
remoteConfig: RemoteConfig,
observer: ConfigUpdateObserver
): Unsubscribe {
const rc = getModularInstance(remoteConfig) as RemoteConfigImpl;
rc._realtimeHandler.addObserver(observer);
return () => {
rc._realtimeHandler.removeObserver(observer);
};
}
@@ -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
);
}
}
@@ -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<ConfigUpdateObserver> =
new Set<ConfigUpdateObserver>();
private isConnectionActive: boolean = false;
private isRealtimeDisabled: boolean = false;
private controller?: AbortController;
private reader: ReadableStreamDefaultReader<Uint8Array> | 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<void> {
// 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<void> {
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<void> {
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<void> {
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<void> {
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<Response> {
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<Response> {
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<void> {
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<string> {
const changedKeys = new Set<string>();
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<void> {
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<string> {
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<void> {
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<Uint8Array>
): Promise<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
this.isInBackground = !visible;
if (!visible) {
await this.closeRealtimeHttpConnection();
} else if (visible) {
await this.beginRealtime();
}
}
}
@@ -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 {
* <p>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;
}
@@ -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 };
}
}
@@ -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_];
}
}
+18 -2
View File
@@ -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<ErrorCode, ErrorParams>(
+67 -1
View File
@@ -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 {
*
* <p>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<string>;
}
/**
* 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.
*
* <ul>
* <li>"BASE" indicates a standard fetch request.</li>
* <li>"REALTIME" indicates a fetch request triggered by a real-time update.</li>
* </ul>
*
* @public
*/
export type FetchType = 'BASE' | 'REALTIME';
declare module '@firebase/component' {
interface NameServiceMapping {
'remote-config': RemoteConfig;
+16 -1
View File
@@ -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.
+6 -1
View File
@@ -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
) {}
}
+31 -1
View File
@@ -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<IDBDatabase> {
@@ -178,6 +187,27 @@ export abstract class Storage {
abstract get<T>(key: ProjectNamespaceKeyFieldValue): Promise<T | undefined>;
abstract set<T>(key: ProjectNamespaceKeyFieldValue, value: T): Promise<void>;
abstract delete(key: ProjectNamespaceKeyFieldValue): Promise<void>;
getRealtimeBackoffMetadata(): Promise<RealtimeBackoffMetadata | undefined> {
return this.get<RealtimeBackoffMetadata>('realtime_backoff_metadata');
}
setRealtimeBackoffMetadata(
realtimeMetadata: RealtimeBackoffMetadata
): Promise<void> {
return this.set<RealtimeBackoffMetadata>(
'realtime_backoff_metadata',
realtimeMetadata
);
}
getActiveConfigTemplateVersion(): Promise<number | undefined> {
return this.get<number>('last_known_template_version');
}
setActiveConfigTemplateVersion(version: number): Promise<void> {
return this.set<number>('last_known_template_version', version);
}
}
export class IndexedDbStorage extends Storage {
+110 -3
View File
@@ -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<void> {
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');
});
});
});
@@ -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<Uint8Array> {
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<Uint8Array> {
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<Storage>;
let mockStorageCache: sinon.SinonStubbedInstance<StorageCache>;
let mockCachingClient: sinon.SinonStubbedInstance<CachingClient>;
let mockLogger: sinon.SinonStubbedInstance<Logger>;
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<Storage>;
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<AbortController>;
let mockReader: sinon.SinonStubbedInstance<
ReadableStreamDefaultReader<Uint8Array>
>;
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<Uint8Array>;
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;
});
});
});
@@ -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
});
}
});
@@ -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
);
});
});