mirror of
https://github.com/hanzoai/firebase-js-sdk.git
synced 2026-09-24 16:59:10 +00:00
Remote Config Realtime for Web (#9221)
This commit is contained in:
committed by
GitHub
parent
0e9b34fbb4
commit
12914fd6d7
@@ -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.
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>
|
||||
|
||||
@@ -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) | () => 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.
|
||||
|
||||
<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;
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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_];
|
||||
}
|
||||
}
|
||||
@@ -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>(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
) {}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user