diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index 213335929dd..1da7c29df0d 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -5,10 +5,23 @@ ```ts import { FirebaseApp } from '@firebase/app'; +import { FirebaseError } from '@firebase/app'; // @public export function activate(remoteConfig: RemoteConfig): Promise; +// @public +export interface ConfigUpdate { + getUpdatedKeys(): Set; +} + +// @public +export interface ConfigUpdateObserver { + complete: () => void; + error: (error: FirebaseError) => void; + next: (configUpdate: ConfigUpdate) => void; +} + // @public export interface CustomSignals { // (undocumented) @@ -64,6 +77,9 @@ export function isSupported(): Promise; // @public export type LogLevel = 'debug' | 'error' | 'silent'; +// @public +export function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Promise; + // @public export interface RemoteConfig { app: FirebaseApp; @@ -93,6 +109,9 @@ export function setCustomSignals(remoteConfig: RemoteConfig, customSignals: Cust // @public export function setLogLevel(remoteConfig: RemoteConfig, logLevel: LogLevel): void; +// @public +export type Unsubscribe = () => void; + // @public export interface Value { asBoolean(): boolean; diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index da7c2500894..b2c9dca36c6 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -607,6 +607,10 @@ toc: - title: remote-config path: /docs/reference/js/remote-config.md section: + - title: ConfigUpdate + path: /docs/reference/js/remote-config.configupdate.md + - title: ConfigUpdateObserver + path: /docs/reference/js/remote-config.configupdateobserver.md - title: CustomSignals path: /docs/reference/js/remote-config.customsignals.md - title: FetchResponse diff --git a/docs-devsite/remote-config.configupdate.md b/docs-devsite/remote-config.configupdate.md new file mode 100644 index 00000000000..231c8b1eb1f --- /dev/null +++ b/docs-devsite/remote-config.configupdate.md @@ -0,0 +1,39 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# ConfigUpdate interface +Contains information about which keys have been updated. + +Signature: + +```typescript +export interface ConfigUpdate +``` + +## Methods + +| Method | Description | +| --- | --- | +| [getUpdatedKeys()](./remote-config.configupdate.md#configupdategetupdatedkeys) | Parameter keys whose values have been updated from the currently activated values. Includes keys that are added, deleted, or whose value, value source, or metadata has changed. | + +## ConfigUpdate.getUpdatedKeys() + +Parameter keys whose values have been updated from the currently activated values. Includes keys that are added, deleted, or whose value, value source, or metadata has changed. + +Signature: + +```typescript +getUpdatedKeys(): Set; +``` +Returns: + +Set<string> + diff --git a/docs-devsite/remote-config.configupdateobserver.md b/docs-devsite/remote-config.configupdateobserver.md new file mode 100644 index 00000000000..93f9154bb91 --- /dev/null +++ b/docs-devsite/remote-config.configupdateobserver.md @@ -0,0 +1,59 @@ +Project: /docs/reference/js/_project.yaml +Book: /docs/reference/_book.yaml +page_type: reference + +{% comment %} +DO NOT EDIT THIS FILE! +This is generated by the JS SDK team, and any local changes will be +overwritten. Changes should be made in the source code at +https://github.com/firebase/firebase-js-sdk +{% endcomment %} + +# ConfigUpdateObserver interface +Observer interface for receiving real-time Remote Config update notifications. + +NOTE: Although an `complete` callback can be provided, it will never be called because the ConfigUpdate stream is never-ending. + +Signature: + +```typescript +export interface ConfigUpdateObserver +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [complete](./remote-config.configupdateobserver.md#configupdateobservercomplete) | () => void | Called when the stream is gracefully terminated. | +| [error](./remote-config.configupdateobserver.md#configupdateobservererror) | (error: [FirebaseError](./util.firebaseerror.md#firebaseerror_class)) => void | Called if an error occurs during the stream. | +| [next](./remote-config.configupdateobserver.md#configupdateobservernext) | (configUpdate: [ConfigUpdate](./remote-config.configupdate.md#configupdate_interface)) => void | Called when a new ConfigUpdate is available. | + +## ConfigUpdateObserver.complete + +Called when the stream is gracefully terminated. + +Signature: + +```typescript +complete: () => void; +``` + +## ConfigUpdateObserver.error + +Called if an error occurs during the stream. + +Signature: + +```typescript +error: (error: FirebaseError) => void; +``` + +## ConfigUpdateObserver.next + +Called when a new ConfigUpdate is available. + +Signature: + +```typescript +next: (configUpdate: ConfigUpdate) => void; +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 58d23cfd647..1b8232588de 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -28,6 +28,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | [getNumber(remoteConfig, key)](./remote-config.md#getnumber_476c09f) | Gets the value for the given key as a number.Convenience method for calling remoteConfig.getValue(key).asNumber(). | | [getString(remoteConfig, key)](./remote-config.md#getstring_476c09f) | Gets the value for the given key as a string. Convenience method for calling remoteConfig.getValue(key).asString(). | | [getValue(remoteConfig, key)](./remote-config.md#getvalue_476c09f) | Gets the [Value](./remote-config.value.md#value_interface) for the given key. | +| [onConfigUpdate(remoteConfig, observer)](./remote-config.md#onconfigupdate_8b13b26) | Starts listening for real-time config updates from the Remote Config backend and automatically fetches updates from the RC backend when they are available.

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. | | [setCustomSignals(remoteConfig, customSignals)](./remote-config.md#setcustomsignals_aeeb95e) | Sets the custom signals for the app instance. | | [setLogLevel(remoteConfig, logLevel)](./remote-config.md#setloglevel_039a45b) | Defines the log level to use. | | function() | @@ -37,6 +38,8 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Interface | Description | | --- | --- | +| [ConfigUpdate](./remote-config.configupdate.md#configupdate_interface) | Contains information about which keys have been updated. | +| [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) | Observer interface for receiving real-time Remote Config update notifications.NOTE: Although an complete callback can be provided, it will never be called because the ConfigUpdate stream is never-ending. | | [CustomSignals](./remote-config.customsignals.md#customsignals_interface) | Defines the type for representing custom signals and their values.

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

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

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

  • "no-fetch-yet" indicates the [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) instance has not yet attempted to fetch config, or that SDK initialization is incomplete.
  • "success" indicates the last attempt succeeded.
  • "failure" indicates the last attempt failed.
  • "throttle" indicates the last attempt was rate-limited.
| | [LogLevel](./remote-config.md#loglevel) | Defines levels of Remote Config logging. | +| [Unsubscribe](./remote-config.md#unsubscribe) | A function that unsubscribes from a real-time event stream. | | [ValueSource](./remote-config.md#valuesource) | Indicates the source of a value.
  • "static" indicates the value was defined by a static constant.
  • "default" indicates the value was defined by default config.
  • "remote" indicates the value was defined by fetched config.
| ## function(app, ...) @@ -282,6 +286,31 @@ export declare function getValue(remoteConfig: RemoteConfig, key: string): Value The value for the given key. +### onConfigUpdate(remoteConfig, observer) {:#onconfigupdate_8b13b26} + +Starts listening for real-time config updates from the Remote Config backend and automatically fetches updates from the RC backend when they are available. + +

If a connection to the Remote Config backend is not already open, calling this method will open it. Multiple listeners can be added by calling this method again, but subsequent calls re-use the same connection to the backend. + +Signature: + +```typescript +export declare function onConfigUpdate(remoteConfig: RemoteConfig, observer: ConfigUpdateObserver): Promise; +``` + +#### Parameters + +| Parameter | Type | Description | +| --- | --- | --- | +| remoteConfig | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) instance. | +| observer | [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) | The [ConfigUpdateObserver](./remote-config.configupdateobserver.md#configupdateobserver_interface) to be notified of config updates. | + +Returns: + +Promise<[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. @@ -365,6 +394,16 @@ Defines levels of Remote Config logging. export type LogLevel = 'debug' | 'error' | 'silent'; ``` +## Unsubscribe + +A function that unsubscribes from a real-time event stream. + +Signature: + +```typescript +export type Unsubscribe = () => void; +``` + ## ValueSource Indicates the source of a value. diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index 556302d773c..dfad010fde4 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -44,6 +44,7 @@ "@firebase/logger": "0.5.0", "@firebase/util": "1.13.0", "@firebase/component": "0.7.0", + "date-fns": "4.1.0", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 1431864edd5..47ae2d2af64 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -22,7 +22,9 @@ import { LogLevel as RemoteConfigLogLevel, RemoteConfig, Value, - RemoteConfigOptions + RemoteConfigOptions, + ConfigUpdateObserver, + Unsubscribe } from './public_types'; import { RemoteConfigAbortSignal } from './client/remote_config_fetch_client'; import { @@ -351,3 +353,29 @@ export async function setCustomSignals( ); } } + +// TODO: Add public document for the Remote Config Realtime API guide on the Web Platform. +/** + * Starts listening for real-time config updates from the Remote Config backend and automatically + * fetches updates from the RC backend when they are available. + * + *

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 async function onConfigUpdate( + remoteConfig: RemoteConfig, + observer: ConfigUpdateObserver +): Promise { + const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; + await rc._realtimeHandler.addObserver(observer); + return () => { + rc._realtimeHandler.removeObserver(observer); + }; +} diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts new file mode 100644 index 00000000000..253e4f80873 --- /dev/null +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -0,0 +1,347 @@ +/** + * @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 { ConfigUpdateObserver } from '../public_types'; +import { calculateBackoffMillis, FirebaseError } from '@firebase/util'; +import { ERROR_FACTORY, ErrorCode } from '../errors'; +import { Storage } from '../storage/storage'; +import { isBefore } from 'date-fns'; + +const API_KEY_HEADER = 'X-Goog-Api-Key'; +const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; +const ORIGINAL_RETRIES = 8; +const NO_BACKOFF_TIME_IN_MILLIS = -1; +const NO_FAILED_REALTIME_STREAMS = 0; + +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 + ) { + void this.setRetriesRemaining(); + } + + private observers: Set = + new Set(); + private isConnectionActive: boolean = false; + private isRealtimeDisabled: boolean = false; + private controller?: AbortController; + private reader: ReadableStreamDefaultReader | undefined; + private httpRetriesRemaining: number = ORIGINAL_RETRIES; + + private async setRetriesRemaining(): Promise { + // Retrieve number of remaining retries from last session. The minimum retry count being one. + const metadata = await this.storage.getRealtimeBackoffMetadata(); + const numFailedStreams = metadata?.numFailedStreams || 0; + this.httpRetriesRemaining = Math.max( + ORIGINAL_RETRIES - numFailedStreams, + 1 + ); + } + + private propagateError = (e: FirebaseError): void => + this.observers.forEach(o => o.error?.(e)); + + /** + * Increment the number of failed stream attempts, increase the backoff duration, set the backoff + * end time to "backoff duration" after {@code lastFailedStreamTime} and persist the new + * values to storage metadata. + */ + private async updateBackoffMetadataWithLastFailedStreamConnectionTime( + lastFailedStreamTime: Date + ): Promise { + const numFailedStreams = + ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || + 0) + 1; + const backoffMillis = calculateBackoffMillis(numFailedStreams); + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: new Date( + lastFailedStreamTime.getTime() + backoffMillis + ), + numFailedStreams + }); + } + + /** + * 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); + }; + + /** + * Stops the real-time HTTP connection by aborting the in-progress fetch request + * and canceling the stream reader if they exist. + */ + private closeRealtimeHttpConnection(): void { + if (this.controller) { + this.controller.abort(); + this.controller = undefined; + } + + if (this.reader) { + void this.reader.cancel(); + this.reader = undefined; + } + } + + private async resetRealtimeBackoff(): Promise { + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + }); + } + + private resetRetryCount(): void { + this.httpRetriesRemaining = ORIGINAL_RETRIES; + } + + /** + * Assembles the request headers and body and executes the fetch request to + * establish the real-time streaming connection. This is the "worker" method + * that performs the actual network communication. + */ + private async establishRealtimeConnection( + url: URL, + installationId: string, + installationTokenResult: string, + signal: AbortSignal + ): Promise { + const eTagValue = await this.storage.getActiveConfigEtag(); + const lastKnownVersionNumber = + await this.storage.getLastKnownTemplateVersion(); + + const headers = { + [API_KEY_HEADER]: this.apiKey, + [INSTALLATIONS_AUTH_TOKEN_HEADER]: installationTokenResult, + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'If-None-Match': eTagValue || '*', + 'Content-Encoding': 'gzip' + }; + + const requestBody = { + project: this.projectId, + namespace: this.namespace, + lastKnownVersionNumber, + appId: this.appId, + sdkVersion: this.sdkVersion, + appInstanceId: installationId + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + signal + }); + return response; + } + + private getRealtimeUrl(): URL { + const urlBase = + window.FIREBASE_REMOTE_CONFIG_URL_BASE || + 'https://firebaseremoteconfigrealtime.googleapis.com'; + + const urlString = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:streamFetchInvalidations?key=${this.apiKey}`; + return new URL(urlString); + } + + private async createRealtimeConnection(): Promise { + const [installationId, installationTokenResult] = await Promise.all([ + this.firebaseInstallations.getId(), + this.firebaseInstallations.getToken(false) + ]); + this.controller = new AbortController(); + const url = this.getRealtimeUrl(); + const realtimeConnection = await this.establishRealtimeConnection( + url, + installationId, + installationTokenResult, + this.controller.signal + ); + return realtimeConnection; + } + + /** + * Retries HTTP stream connection asyncly in random time intervals. + */ + private async retryHttpConnectionWhenBackoffEnds(): Promise { + let backoffMetadata = await this.storage.getRealtimeBackoffMetadata(); + if (!backoffMetadata) { + backoffMetadata = { + backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), + numFailedStreams: NO_FAILED_REALTIME_STREAMS + }; + } + const backoffEndTime = new Date( + backoffMetadata.backoffEndTimeMillis + ).getTime(); + const currentTime = Date.now(); + const retryMillis = Math.max(0, backoffEndTime - currentTime); + await this.makeRealtimeHttpConnection(retryMillis); + } + + private setIsHttpConnectionRunning(connectionRunning: boolean): void { + this.isConnectionActive = connectionRunning; + } + + private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { + const canMakeConnection = this.canEstablishStreamConnection(); + if (canMakeConnection) { + this.setIsHttpConnectionRunning(true); + } + return canMakeConnection; + } + + /** + * 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 beginRealtimeHttpStream(): Promise { + if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) { + return; + } + + let backoffMetadata = await this.storage.getRealtimeBackoffMetadata(); + if (!backoffMetadata) { + backoffMetadata = { + backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), + numFailedStreams: NO_FAILED_REALTIME_STREAMS + }; + } + const backoffEndTime = backoffMetadata.backoffEndTimeMillis.getTime(); + if (isBefore(new Date(), backoffEndTime)) { + await this.retryHttpConnectionWhenBackoffEnds(); + return; + } + + let response: Response | undefined; + let responseCode: number | undefined; + try { + //this has been called in the try cause it throws an error if the method does not get implemented + response = await this.createRealtimeConnection(); + responseCode = response.status; + if (response.ok && response.body) { + this.resetRetryCount(); + await this.resetRealtimeBackoff(); + //const configAutoFetch = this.startAutoFetch(reader); + //await configAutoFetch.listenForNotifications(); + } + } catch (error) { + //there might have been a transient error so the client will retry the connection. + this.logger.error( + 'Exception connecting to real-time RC backend. Retrying the connection...:', + error + ); + } finally { + // Close HTTP connection and associated streams. + this.closeRealtimeHttpConnection(); + this.setIsHttpConnectionRunning(false); + + // Update backoff metadata if the connection failed in the foreground. + const connectionFailed = + responseCode == null || 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, + { + httpStatus: responseCode, + 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; + return hasActiveListeners && isNotDisabled && isNoConnectionActive; + } + + private async makeRealtimeHttpConnection(delayMillis: number): Promise { + if (!this.canEstablishStreamConnection()) { + return; + } + if (this.httpRetriesRemaining > 0) { + this.httpRetriesRemaining--; + setTimeout(async () => { + await this.beginRealtimeHttpStream(); + }, delayMillis); + } + } + + private async beginRealtime(): Promise { + if (this.observers.size > 0) { + await this.makeRealtimeHttpConnection(0); + } + } + + /** + * Adds an observer to the realtime updates. + * @param observer The observer to add. + */ + async addObserver(observer: ConfigUpdateObserver): Promise { + this.observers.add(observer); + await 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); + } + } +} diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index 446bd2c6e7a..ac7c71b3218 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -33,7 +33,8 @@ 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' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -72,7 +73,9 @@ 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.' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -92,6 +95,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]: { + httpStatus?: number; + originalErrorMessage?: string; + }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index 927bc84ca10..e65b9557b9e 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app'; +import { FirebaseApp, FirebaseError } from '@firebase/app'; /** * The Firebase Remote Config service interface. @@ -212,6 +212,51 @@ export interface CustomSignals { [key: string]: string | number | null; } +/** + * Contains information about which keys have been updated. + * + * @public + */ +export interface ConfigUpdate { + /** + * Parameter keys whose values have been updated from the currently activated values. + * Includes keys that are added, deleted, or whose value, value source, or metadata has changed. + */ + getUpdatedKeys(): Set; +} + +/** + * Observer interface for receiving real-time Remote Config update notifications. + * + * NOTE: Although an `complete` callback can be provided, it will + * never be called because the ConfigUpdate stream is never-ending. + * + * @public + */ +export interface ConfigUpdateObserver { + /** + * Called when a new ConfigUpdate is available. + */ + next: (configUpdate: ConfigUpdate) => void; + + /** + * Called if an error occurs during the stream. + */ + error: (error: FirebaseError) => void; + + /** + * Called when the stream is gracefully terminated. + */ + complete: () => void; +} + +/** + * A function that unsubscribes from a real-time event stream. + * + * @public + */ +export type Unsubscribe = () => void; + declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index 160e20219ce..df54439b3f5 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -37,6 +37,7 @@ import { ErrorCode, ERROR_FACTORY } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; import { IndexedDbStorage, InMemoryStorage } from './storage/storage'; import { StorageCache } from './storage/storage_cache'; +import { RealtimeHandler } from './client/realtime_handler'; // This needs to be in the same file that calls `getProvider()` on the component // or it will get tree-shaken out. import '@firebase/installations'; @@ -107,12 +108,24 @@ export function registerRemoteConfig(): void { logger ); + const realtimehandler = new RealtimeHandler( + installations, + storage, + SDK_VERSION, + namespace, + projectId, + apiKey, + appId, + logger + ); + const remoteConfigInstance = new RemoteConfigImpl( app, cachingClient, storageCache, storage, - logger + logger, + realtimehandler ); // Starts warming cache. diff --git a/packages/remote-config/src/remote_config.ts b/packages/remote-config/src/remote_config.ts index bd2db66d0b3..bd32c938304 100644 --- a/packages/remote-config/src/remote_config.ts +++ b/packages/remote-config/src/remote_config.ts @@ -25,6 +25,7 @@ import { StorageCache } from './storage/storage_cache'; import { RemoteConfigFetchClient } from './client/remote_config_fetch_client'; import { Storage } from './storage/storage'; import { Logger } from '@firebase/logger'; +import { RealtimeHandler } from './client/realtime_handler'; const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute const DEFAULT_CACHE_MAX_AGE_MILLIS = 12 * 60 * 60 * 1000; // Twelve hours. @@ -83,6 +84,10 @@ export class RemoteConfig implements RemoteConfigType { /** * @internal */ - readonly _logger: Logger + readonly _logger: Logger, + /** + * @internal + */ + readonly _realtimeHandler: RealtimeHandler ) {} } diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index f03ff41377b..8dd767ef101 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -56,6 +56,13 @@ export interface ThrottleMetadata { throttleEndTimeMillis: number; } +export interface RealtimeBackoffMetadata { + // The number of consecutive connection streams that have failed. + numFailedStreams: number; + // The Date until which the client should wait before attempting any new real-time connections. + backoffEndTimeMillis: Date; +} + /** * Provides type-safety for the "key" field used by {@link APP_NAMESPACE_STORE}. * @@ -69,7 +76,9 @@ type ProjectNamespaceKeyFieldValue = | 'last_successful_fetch_response' | 'settings' | 'throttle_metadata' - | 'custom_signals'; + | 'custom_signals' + | 'realtime_backoff_metadata' + | 'last_known_template_version'; // Visible for testing. export function openDatabase(): Promise { @@ -178,6 +187,23 @@ export abstract class Storage { abstract get(key: ProjectNamespaceKeyFieldValue): Promise; abstract set(key: ProjectNamespaceKeyFieldValue, value: T): Promise; abstract delete(key: ProjectNamespaceKeyFieldValue): Promise; + + getRealtimeBackoffMetadata(): Promise { + return this.get('realtime_backoff_metadata'); + } + + getLastKnownTemplateVersion(): Promise { + return this.get('last_known_template_version'); + } + + setRealtimeBackoffMetadata( + realtimeMetadata: RealtimeBackoffMetadata + ): Promise { + return this.set( + 'realtime_backoff_metadata', + realtimeMetadata + ); + } } export class IndexedDbStorage extends Storage { diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 8010f54f26d..2ee9e71eccf 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -46,6 +46,7 @@ import { import * as api from '../src/api'; import { fetchAndActivate } from '../src'; import { restore } from 'sinon'; +import { RealtimeHandler } from '../src/client/realtime_handler'; describe('RemoteConfig', () => { const ACTIVE_CONFIG = { @@ -67,6 +68,7 @@ describe('RemoteConfig', () => { let storageCache: StorageCache; let storage: Storage; let logger: Logger; + let realtimeHandler: RealtimeHandler; let rc: RemoteConfigType; let getActiveConfigStub: sinon.SinonStub; @@ -79,12 +81,20 @@ describe('RemoteConfig', () => { client = {} as RemoteConfigFetchClient; storageCache = {} as StorageCache; storage = {} as Storage; + realtimeHandler = {} as RealtimeHandler; logger = new Logger('package-name'); getActiveConfigStub = sinon.stub().returns(undefined); storageCache.getActiveConfig = getActiveConfigStub; loggerDebugSpy = sinon.spy(logger, 'debug'); loggerLogLevelSpy = sinon.spy(logger, 'logLevel', ['set']); - rc = new RemoteConfig(app, client, storageCache, storage, logger); + rc = new RemoteConfig( + app, + client, + storageCache, + storage, + logger, + realtimeHandler + ); }); afterEach(() => { diff --git a/yarn.lock b/yarn.lock index 540d7bc7171..0083e9bc0fd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6015,6 +6015,11 @@ dataloader@^1.4.0: resolved "https://registry.npmjs.org/dataloader/-/dataloader-1.4.0.tgz#bca11d867f5d3f1b9ed9f737bd15970c65dff5c8" integrity sha512-68s5jYdlvasItOJnCuI2Q9s4q98g0pCyL3HrcKJu8KNugUl8ahgmZYg38ysLTgQjjXX3H8CJLkAvWrclWfcalw== +date-fns@4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz#64b3d83fff5aa80438f5b1a633c2e83b8a1c2d14" + integrity sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg== + date-fns@^1.27.2: version "1.30.1" resolved "https://registry.npmjs.org/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"