From 22ba36698b2420158f4b7e4eb99ee6fcad617f94 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Fri, 8 Aug 2025 12:37:50 +0530 Subject: [PATCH 1/7] added the api-definition, interfaces, realtime connection and backoffLogic --- packages/remote-config/src/api.ts | 28 +- .../src/client/realtime_handler.ts | 283 ++++++++++++++++++ packages/remote-config/src/errors.ts | 8 +- packages/remote-config/src/public_types.ts | 44 ++- packages/remote-config/src/register.ts | 13 +- packages/remote-config/src/remote_config.ts | 9 +- packages/remote-config/src/storage/storage.ts | 26 +- 7 files changed, 400 insertions(+), 11 deletions(-) create mode 100644 packages/remote-config/src/client/realtime_handler.ts diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 1431864edd5..69bf46110e8 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 { @@ -256,7 +258,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { if (!rc._isInitializationComplete) { rc._logger.debug( `A value was requested for key "${key}" before SDK initialization completed.` + - ' Await on ensureInitialized if the intent was to get a previously activated value.' + ' Await on ensureInitialized if the intent was to get a previously activated value.' ); } const activeConfig = rc._storageCache.getActiveConfig(); @@ -267,7 +269,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { } rc._logger.debug( `Returning static value for key "${key}".` + - ' Define a default or remote value if this is unintentional.' + ' Define a default or remote value if this is unintentional.' ); return new ValueImpl('static'); } @@ -351,3 +353,23 @@ export async function setCustomSignals( ); } } + +/** + * Registers a real-time listener for Remote Config updates. + * + * @param remoteConfig - The {@link RemoteConfig} instance. + * @param observer - The {@link ConfigUpdateObserver} to be notified of config updates. + * @returns An {@link Unsubscribe} function to remove the listener. + * + * @public + */ +export function onConfigUpdate( + remoteConfig: RemoteConfig, + observer: ConfigUpdateObserver +): Unsubscribe { + const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; + rc._realtimeHandler.addObserver(observer); + return () => { + rc._realtimeHandler.removeObserver(observer); + }; +} diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts new file mode 100644 index 00000000000..a1db8f4342d --- /dev/null +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -0,0 +1,283 @@ +/** + * @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 { ConfigUpdate, ConfigUpdateObserver } from "../public_types"; +import { calculateBackoffMillis, FirebaseError } from "@firebase/util"; +import { ERROR_FACTORY, ErrorCode } from "../errors"; +import { Storage } from "../storage/storage"; +const ORIGINAL_RETRIES = 8; +const API_KEY_HEADER = 'X-Goog-Api-Key'; +const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; +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 observers: Set = new Set(); + private isConnectionActive: boolean = false; + private retriesRemaining: number = ORIGINAL_RETRIES; + private isRealtimeDisabled: boolean = false; + private scheduledConnectionTimeoutId?: ReturnType; + private controller?: AbortController; + private reader: ReadableStreamDefaultReader | undefined; + + /** + * 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); + } + if (this.observers.size === 0) { + // this.stopRealtime(); + } + } + + /** + * 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 beginRealtime(): Promise { + if (this.observers.size > 0) { + await this.makeRealtimeHttpConnection(0); + } + } + + private async makeRealtimeHttpConnection(delayMillis: number): Promise { + if (!this.canEstablishStreamConnection()) { + return; + } + if (this.retriesRemaining > 0) { + this.retriesRemaining--; + console.log(this.retriesRemaining); + this.scheduledConnectionTimeoutId = setTimeout(async () => { + await this.beginRealtimeHttpStream(); + }, delayMillis); + } + } + + private propagateError = (e: FirebaseError) => this.observers.forEach(o => o.error?.(e)); + + private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { + let canMakeConnection: boolean; + canMakeConnection = this.canEstablishStreamConnection(); + if (canMakeConnection) { + this.setIsHttpConnectionRunning(true); + } + return canMakeConnection; + } + + private setIsHttpConnectionRunning(connectionRunning: boolean): void { + this.isConnectionActive = connectionRunning; + } + + private async beginRealtimeHttpStream(): Promise { + if (!this.checkAndSetHttpConnectionFlagIfNotRunning()) { + return; + } + const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata(); + let metadata; + if (metadataFromStorage) { + metadata = metadataFromStorage; + } else { + metadata = { + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + } + await this.storage.setRealtimeBackoffMetadata(metadata); + } + const backoffEndTime = metadata.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 configAutoFetch = this.startAutoFetch(reader); + //await configAutoFetch.listenForNotifications(); + } + } + catch (error) { + console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error); + } + finally { + this.closeRealtimeHttpConnection(); + this.setIsHttpConnectionRunning(false); + const connectionFailed = responseCode == null || this.isStatusCodeRetryable(responseCode); + + if (connectionFailed) { + await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date()); + } + + if (connectionFailed || response?.ok) { + await this.retryHttpConnectionWhenBackoffEnds(); + } else { + let errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`; + if (responseCode === 403) { + if (response) { + errorMessage = await this.parseForbiddenErrorResponseMessage(response); + } + } + const firebaseError = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { + httpStatus: responseCode, + originalErrorMessage: errorMessage + }); + this.propagateError(firebaseError); + } + } + } + + private async retryHttpConnectionWhenBackoffEnds(): Promise { + const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata(); + let metadata; + if (metadataFromStorage) { + metadata = metadataFromStorage; + } else { + metadata = { + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + } + await this.storage.setRealtimeBackoffMetadata(metadata); + } + const backoffEndTime = new Date(metadata.backoffEndTimeMillis).getTime(); + const currentTime = Date.now(); + const retryMillis = Math.max(0, backoffEndTime - currentTime); + this.makeRealtimeHttpConnection(retryMillis); + } + + private async resetRealtimeBackoff(): Promise { + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: new Date(-1), + numFailedStreams: 0 + }); + } + + private resetRetryCount(): void { + this.retriesRemaining = ORIGINAL_RETRIES; + } + + private isStatusCodeRetryable = (sc?: number) => !sc || [408, 429, 502, 503, 504].includes(sc); + + 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 + }); + } + + private async createRealtimeConnection(): Promise { + this.controller = new AbortController(); + const [installationId, installationTokenResult] = await Promise.all([ + this.firebaseInstallations.getId(), + this.firebaseInstallations.getToken(false) + ]); + let response: Response; + const url = this.getRealtimeUrl(); + response = await this.setRequestParams(url, installationId, installationTokenResult, this.controller.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 setRequestParams(url: URL, installationId: string, installationTokenResult: string, signal: AbortSignal): Promise { + const eTagValue = await this.storage.getActiveConfigEtag(); + 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: await this.storage.getLastKnownTemplateVersion(), + appId: this.appId, + sdkVersion: this.sdkVersion, + appInstanceId: installationId + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + signal: signal + }); + return response; + } + + private parseForbiddenErrorResponseMessage(response: Response): Promise { + const error = response.text(); + return error; + } + + private closeRealtimeHttpConnection(): void { + if (this.controller) { + this.controller.abort(); + this.controller = undefined; + } + + if (this.reader) { + this.reader.cancel(); + this.reader = undefined; + } + } +} \ No newline at end of file diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index 446bd2c6e7a..0774b1615b6 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,7 @@ 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..acbb6efdaec 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,48 @@ export interface CustomSignals { [key: string]: string | number | null; } +/** + * Observer interface for receiving real-time Remote Config update notifications. + * + * @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; + +/** + * 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; +} + 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..6a6f7f865d3 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,22 @@ export function registerRemoteConfig(): void { logger ); + const realtimehandler = new RealtimeHandler( + installations, + storage, + SDK_VERSION, + namespace, + projectId, + apiKey, + appId); + 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..82c2d64266c 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..5f2f5b795d3 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,19 @@ export abstract class Storage { abstract get(key: ProjectNamespaceKeyFieldValue): Promise; abstract set(key: ProjectNamespaceKeyFieldValue, value: T): Promise; abstract delete(key: ProjectNamespaceKeyFieldValue): Promise; + + setRealtimeBackoffMetadata(realtime_metadata: RealtimeBackoffMetadata): Promise { + return this.set('realtime_backoff_metadata', realtime_metadata); + } + + getRealtimeBackoffMetadata(): Promise { + return this.get('realtime_backoff_metadata'); + } + + getLastKnownTemplateVersion(): Promise { + return this.get('last_known_template_version'); + } + } export class IndexedDbStorage extends Storage { @@ -390,4 +412,4 @@ function mergeCustomSignals( }); } return updatedSignals; -} +} \ No newline at end of file From 77bc5efd1eac043abaa5857922a68a73353cded1 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Fri, 8 Aug 2025 22:01:43 +0530 Subject: [PATCH 2/7] Added TODO and comments --- packages/remote-config/src/api.ts | 12 +- .../src/client/realtime_handler.ts | 384 ++++++++++-------- packages/remote-config/src/public_types.ts | 29 +- packages/remote-config/src/storage/storage.ts | 10 +- 4 files changed, 236 insertions(+), 199 deletions(-) diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 69bf46110e8..0e2d9d0be78 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -258,7 +258,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { if (!rc._isInitializationComplete) { rc._logger.debug( `A value was requested for key "${key}" before SDK initialization completed.` + - ' Await on ensureInitialized if the intent was to get a previously activated value.' + ' Await on ensureInitialized if the intent was to get a previously activated value.' ); } const activeConfig = rc._storageCache.getActiveConfig(); @@ -269,7 +269,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { } rc._logger.debug( `Returning static value for key "${key}".` + - ' Define a default or remote value if this is unintentional.' + ' Define a default or remote value if this is unintentional.' ); return new ValueImpl('static'); } @@ -354,8 +354,14 @@ export async function setCustomSignals( } } +// TODO: Add public document for the Remote Config Realtime API guide on the Web Platform. /** - * Registers a real-time listener for Remote Config updates. + * 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. diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index a1db8f4342d..31cf889e7dc 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -16,13 +16,18 @@ */ import { _FirebaseInstallationsInternal } from "@firebase/installations"; -import { ConfigUpdate, ConfigUpdateObserver } from "../public_types"; +import { ConfigUpdateObserver } from "../public_types"; import { calculateBackoffMillis, FirebaseError } from "@firebase/util"; import { ERROR_FACTORY, ErrorCode } from "../errors"; import { Storage } from "../storage/storage"; -const ORIGINAL_RETRIES = 8; +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, @@ -32,25 +37,25 @@ export class RealtimeHandler { private readonly projectId: string, private readonly apiKey: string, private readonly appId: string, - ) { } + ) { + this.httpRetriesRemaining = ORIGINAL_RETRIES; + this.setRetriesRemaining(); + } private observers: Set = new Set(); private isConnectionActive: boolean = false; - private retriesRemaining: number = ORIGINAL_RETRIES; private isRealtimeDisabled: boolean = false; - private scheduledConnectionTimeoutId?: ReturnType; private controller?: AbortController; private reader: ReadableStreamDefaultReader | undefined; + private httpRetriesRemaining: number = ORIGINAL_RETRIES; - /** - * 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(); + private async setRetriesRemaining() { + // Retrieve number of remaining retries from last session. The minimum retry count being one. + const metadata = await this.storage.getRealtimeBackoffMetadata(); + const numFailedStreams = metadata?.numFailedStreams || 0; + this.httpRetriesRemaining= Math.max(ORIGINAL_RETRIES - numFailedStreams, 1); } - + /** * Removes an observer from the realtime updates. * @param observer The observer to remove. @@ -59,113 +64,204 @@ export class RealtimeHandler { if (this.observers.has(observer)) { this.observers.delete(observer); } - if (this.observers.size === 0) { - // this.stopRealtime(); - } } + private propagateError = (e: FirebaseError) => this.observers.forEach(o => o.error?.(e)); + /** - * 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; + * 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; + } - private async beginRealtime(): Promise { - if (this.observers.size > 0) { - await this.makeRealtimeHttpConnection(0); + if (this.reader) { + 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', + }; - private async makeRealtimeHttpConnection(delayMillis: number): Promise { - if (!this.canEstablishStreamConnection()) { - return; - } - if (this.retriesRemaining > 0) { - this.retriesRemaining--; - console.log(this.retriesRemaining); - this.scheduledConnectionTimeoutId = setTimeout(async () => { - await this.beginRealtimeHttpStream(); - }, delayMillis); - } + const requestBody = { + project: this.projectId, + namespace: this.namespace, + lastKnownVersionNumber: lastKnownVersionNumber, + appId: this.appId, + sdkVersion: this.sdkVersion, + appInstanceId: installationId + }; + + const response = await fetch(url, { + method: 'POST', + headers, + body: JSON.stringify(requestBody), + signal: signal + }); + return response; } - private propagateError = (e: FirebaseError) => this.observers.forEach(o => o.error?.(e)); + 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(); + return await this.establishRealtimeConnection(url, installationId, installationTokenResult, this.controller.signal); + } + + /** + * 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); + this.makeRealtimeHttpConnection(retryMillis); + } + + private setIsHttpConnectionRunning(connectionRunning: boolean): void { + this.isConnectionActive = connectionRunning; + } private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { - let canMakeConnection: boolean; - canMakeConnection = this.canEstablishStreamConnection(); + const canMakeConnection = this.canEstablishStreamConnection(); if (canMakeConnection) { this.setIsHttpConnectionRunning(true); } return canMakeConnection; } - private setIsHttpConnectionRunning(connectionRunning: boolean): void { - this.isConnectionActive = connectionRunning; - } - + /** + * 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; } - const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata(); - let metadata; - if (metadataFromStorage) { - metadata = metadataFromStorage; - } else { - metadata = { - backoffEndTimeMillis: new Date(-1), - numFailedStreams: 0 - } - await this.storage.setRealtimeBackoffMetadata(metadata); + let backoffMetadata = await this.storage.getRealtimeBackoffMetadata(); + if (!backoffMetadata) { + backoffMetadata = { + backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), + numFailedStreams: NO_FAILED_REALTIME_STREAMS + } } - const backoffEndTime = metadata.backoffEndTimeMillis.getTime(); - - if (Date.now() < backoffEndTime) { + const backoffEndTime = backoffMetadata.backoffEndTimeMillis.getTime(); + if (isBefore(new Date(), 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 configAutoFetch = this.startAutoFetch(reader); - //await configAutoFetch.listenForNotifications(); - } - } - catch (error) { + //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. console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error); - } - finally { + } 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 (connectionFailed || response?.ok) { + // 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 { let errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`; - if (responseCode === 403) { - if (response) { - errorMessage = await this.parseForbiddenErrorResponseMessage(response); - } - } const firebaseError = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { httpStatus: responseCode, originalErrorMessage: errorMessage @@ -175,109 +271,41 @@ export class RealtimeHandler { } } - private async retryHttpConnectionWhenBackoffEnds(): Promise { - const metadataFromStorage = await this.storage.getRealtimeBackoffMetadata(); - let metadata; - if (metadataFromStorage) { - metadata = metadataFromStorage; - } else { - metadata = { - backoffEndTimeMillis: new Date(-1), - numFailedStreams: 0 - } - await this.storage.setRealtimeBackoffMetadata(metadata); - } - const backoffEndTime = new Date(metadata.backoffEndTimeMillis).getTime(); - const currentTime = Date.now(); - const retryMillis = Math.max(0, backoffEndTime - currentTime); - this.makeRealtimeHttpConnection(retryMillis); - } - - private async resetRealtimeBackoff(): Promise { - await this.storage.setRealtimeBackoffMetadata({ - backoffEndTimeMillis: new Date(-1), - numFailedStreams: 0 - }); - } - - private resetRetryCount(): void { - this.retriesRemaining = ORIGINAL_RETRIES; - } - - private isStatusCodeRetryable = (sc?: number) => !sc || [408, 429, 502, 503, 504].includes(sc); - - 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 - }); - } - - private async createRealtimeConnection(): Promise { - this.controller = new AbortController(); - const [installationId, installationTokenResult] = await Promise.all([ - this.firebaseInstallations.getId(), - this.firebaseInstallations.getToken(false) - ]); - let response: Response; - const url = this.getRealtimeUrl(); - response = await this.setRequestParams(url, installationId, installationTokenResult, this.controller.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 setRequestParams(url: URL, installationId: string, installationTokenResult: string, signal: AbortSignal): Promise { - const eTagValue = await this.storage.getActiveConfigEtag(); - 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: await this.storage.getLastKnownTemplateVersion(), - appId: this.appId, - sdkVersion: this.sdkVersion, - appInstanceId: installationId - }; - - const response = await fetch(url, { - method: 'POST', - headers, - body: JSON.stringify(requestBody), - signal: signal - }); - return response; + /** + * 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 parseForbiddenErrorResponseMessage(response: Response): Promise { - const error = response.text(); - return error; + private async makeRealtimeHttpConnection(delayMillis: number): Promise { + if (!this.canEstablishStreamConnection()) { + return; + } + if (this.httpRetriesRemaining > 0) { + this.httpRetriesRemaining--; + setTimeout(async () => { + await this.beginRealtimeHttpStream(); + }, delayMillis); + } } - private closeRealtimeHttpConnection(): void { - if (this.controller) { - this.controller.abort(); - this.controller = undefined; + private async beginRealtime(): Promise { + if (this.observers.size > 0) { + await this.makeRealtimeHttpConnection(0); } + } - if (this.reader) { - this.reader.cancel(); - this.reader = undefined; - } + /** + * 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(); } -} \ No newline at end of file +} diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index acbb6efdaec..e65b9557b9e 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -212,9 +212,25 @@ 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 { @@ -241,19 +257,6 @@ export interface ConfigUpdateObserver { */ export type Unsubscribe = () => void; -/** - * 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; -} - declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index 5f2f5b795d3..cd990f2c6b3 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -188,10 +188,6 @@ export abstract class Storage { abstract set(key: ProjectNamespaceKeyFieldValue, value: T): Promise; abstract delete(key: ProjectNamespaceKeyFieldValue): Promise; - setRealtimeBackoffMetadata(realtime_metadata: RealtimeBackoffMetadata): Promise { - return this.set('realtime_backoff_metadata', realtime_metadata); - } - getRealtimeBackoffMetadata(): Promise { return this.get('realtime_backoff_metadata'); } @@ -200,6 +196,10 @@ export abstract class Storage { return this.get('last_known_template_version'); } + setRealtimeBackoffMetadata(realtime_metadata: RealtimeBackoffMetadata): Promise { + return this.set('realtime_backoff_metadata', realtime_metadata); + } + } export class IndexedDbStorage extends Storage { @@ -412,4 +412,4 @@ function mergeCustomSignals( }); } return updatedSignals; -} \ No newline at end of file +} From c5d7dfb2b3420895a55db922201571395f3245e6 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Mon, 11 Aug 2025 12:28:21 +0530 Subject: [PATCH 3/7] resolved the comment about the spacing problem --- packages/remote-config/src/api.ts | 4 +- .../src/client/realtime_handler.ts | 179 +++++++++++------- packages/remote-config/src/errors.ts | 7 +- packages/remote-config/src/register.ts | 3 +- packages/remote-config/src/remote_config.ts | 6 +- packages/remote-config/src/storage/storage.ts | 10 +- 6 files changed, 125 insertions(+), 84 deletions(-) diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 0e2d9d0be78..674b97f3bd4 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -258,7 +258,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { if (!rc._isInitializationComplete) { rc._logger.debug( `A value was requested for key "${key}" before SDK initialization completed.` + - ' Await on ensureInitialized if the intent was to get a previously activated value.' + ' Await on ensureInitialized if the intent was to get a previously activated value.' ); } const activeConfig = rc._storageCache.getActiveConfig(); @@ -269,7 +269,7 @@ export function getValue(remoteConfig: RemoteConfig, key: string): Value { } rc._logger.debug( `Returning static value for key "${key}".` + - ' Define a default or remote value if this is unintentional.' + ' Define a default or remote value if this is unintentional.' ); return new ValueImpl('static'); } diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index 31cf889e7dc..bce03bbb87b 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -15,11 +15,11 @@ * limitations under the License. */ -import { _FirebaseInstallationsInternal } from "@firebase/installations"; -import { ConfigUpdateObserver } from "../public_types"; -import { calculateBackoffMillis, FirebaseError } from "@firebase/util"; -import { ERROR_FACTORY, ErrorCode } from "../errors"; -import { Storage } from "../storage/storage"; +import { _FirebaseInstallationsInternal } from '@firebase/installations'; +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'; @@ -36,13 +36,13 @@ export class RealtimeHandler { private readonly namespace: string, private readonly projectId: string, private readonly apiKey: string, - private readonly appId: string, - ) { - this.httpRetriesRemaining = ORIGINAL_RETRIES; + private readonly appId: string + ) { this.setRetriesRemaining(); } - private observers: Set = new Set(); + private observers: Set = + new Set(); private isConnectionActive: boolean = false; private isRealtimeDisabled: boolean = false; private controller?: AbortController; @@ -50,52 +50,52 @@ export class RealtimeHandler { private httpRetriesRemaining: number = ORIGINAL_RETRIES; private async setRetriesRemaining() { - // Retrieve number of remaining retries from last session. The minimum retry count being one. - const metadata = await this.storage.getRealtimeBackoffMetadata(); - const numFailedStreams = metadata?.numFailedStreams || 0; - this.httpRetriesRemaining= Math.max(ORIGINAL_RETRIES - numFailedStreams, 1); - } - - /** - * 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); - } + // 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) => this.observers.forEach(o => o.error?.(e)); + private propagateError = (e: FirebaseError) => + 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; + 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), + 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 + 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. @@ -111,34 +111,40 @@ export class RealtimeHandler { 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 { + */ + 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 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', + 'Content-Encoding': 'gzip' }; const requestBody = { @@ -175,7 +181,12 @@ export class RealtimeHandler { ]); this.controller = new AbortController(); const url = this.getRealtimeUrl(); - return await this.establishRealtimeConnection(url, installationId, installationTokenResult, this.controller.signal); + return await this.establishRealtimeConnection( + url, + installationId, + installationTokenResult, + this.controller.signal + ); } /** @@ -187,9 +198,11 @@ export class RealtimeHandler { backoffMetadata = { backoffEndTimeMillis: new Date(NO_BACKOFF_TIME_IN_MILLIS), numFailedStreams: NO_FAILED_REALTIME_STREAMS - } - } - const backoffEndTime = new Date(backoffMetadata.backoffEndTimeMillis).getTime(); + }; + } + const backoffEndTime = new Date( + backoffMetadata.backoffEndTimeMillis + ).getTime(); const currentTime = Date.now(); const retryMillis = Math.max(0, backoffEndTime - currentTime); this.makeRealtimeHttpConnection(retryMillis); @@ -218,12 +231,13 @@ export class RealtimeHandler { 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)) { @@ -234,47 +248,56 @@ export class RealtimeHandler { 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(); - } + //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. - console.error('Exception connecting to real-time RC backend. Retrying the connection...:', error); + console.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); + const connectionFailed = + responseCode == null || this.isStatusCodeRetryable(responseCode); if (connectionFailed) { - await this.updateBackoffMetadataWithLastFailedStreamConnectionTime(new Date()); + 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 ) { + if (connectionFailed || response?.ok) { await this.retryHttpConnectionWhenBackoffEnds(); } else { let 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 - }); + 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 - */ + * 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; @@ -301,11 +324,21 @@ export class RealtimeHandler { } /** - * Adds an observer to the realtime updates. - * @param observer The observer to add. - */ + * 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 0774b1615b6..ac7c71b3218 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -75,7 +75,7 @@ const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { [ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS]: 'Setting more than {$maxSignals} custom signals is not supported.', [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: - 'The stream was not able to connect to the backend.', + '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 @@ -95,7 +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 }; + [ErrorCode.CONFIG_UPDATE_STREAM_ERROR]: { + httpStatus?: number; + originalErrorMessage?: string; + }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index 6a6f7f865d3..c516e83e048 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -115,7 +115,8 @@ export function registerRemoteConfig(): void { namespace, projectId, apiKey, - appId); + appId + ); const remoteConfigInstance = new RemoteConfigImpl( app, diff --git a/packages/remote-config/src/remote_config.ts b/packages/remote-config/src/remote_config.ts index 82c2d64266c..bd32c938304 100644 --- a/packages/remote-config/src/remote_config.ts +++ b/packages/remote-config/src/remote_config.ts @@ -86,8 +86,8 @@ export class RemoteConfig implements RemoteConfigType { */ readonly _logger: Logger, /** - * @internal - */ + * @internal + */ readonly _realtimeHandler: RealtimeHandler - ) { } + ) {} } diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index cd990f2c6b3..2ba0a4adc00 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -196,10 +196,14 @@ export abstract class Storage { return this.get('last_known_template_version'); } - setRealtimeBackoffMetadata(realtime_metadata: RealtimeBackoffMetadata): Promise { - return this.set('realtime_backoff_metadata', realtime_metadata); + setRealtimeBackoffMetadata( + realtime_metadata: RealtimeBackoffMetadata + ): Promise { + return this.set( + 'realtime_backoff_metadata', + realtime_metadata + ); } - } export class IndexedDbStorage extends Storage { From 77a6aca7d75a0b8166739f07e0a1a9ea98a4e2ed Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Mon, 11 Aug 2025 15:13:34 +0530 Subject: [PATCH 4/7] Fix check failures --- common/api-review/remote-config.api.md | 19 ++++++ docs-devsite/_toc.yaml | 4 ++ docs-devsite/remote-config.configupdate.md | 39 ++++++++++++ .../remote-config.configupdateobserver.md | 59 +++++++++++++++++++ docs-devsite/remote-config.md | 39 ++++++++++++ packages/remote-config/package.json | 1 + packages/remote-config/src/api.ts | 6 +- .../src/client/realtime_handler.ts | 25 ++++---- packages/remote-config/src/register.ts | 3 +- packages/remote-config/src/storage/storage.ts | 4 +- .../remote-config/test/remote_config.test.ts | 12 +++- yarn.lock | 5 ++ 12 files changed, 198 insertions(+), 18 deletions(-) create mode 100644 docs-devsite/remote-config.configupdate.md create mode 100644 docs-devsite/remote-config.configupdateobserver.md 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 674b97f3bd4..47ae2d2af64 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -369,12 +369,12 @@ export async function setCustomSignals( * * @public */ -export function onConfigUpdate( +export async function onConfigUpdate( remoteConfig: RemoteConfig, observer: ConfigUpdateObserver -): Unsubscribe { +): Promise { const rc = getModularInstance(remoteConfig) as RemoteConfigImpl; - rc._realtimeHandler.addObserver(observer); + 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 index bce03bbb87b..253e4f80873 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -16,6 +16,7 @@ */ 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'; @@ -36,9 +37,10 @@ export class RealtimeHandler { private readonly namespace: string, private readonly projectId: string, private readonly apiKey: string, - private readonly appId: string + private readonly appId: string, + private readonly logger: Logger ) { - this.setRetriesRemaining(); + void this.setRetriesRemaining(); } private observers: Set = @@ -49,7 +51,7 @@ export class RealtimeHandler { private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; - private async setRetriesRemaining() { + 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; @@ -59,7 +61,7 @@ export class RealtimeHandler { ); } - private propagateError = (e: FirebaseError) => + private propagateError = (e: FirebaseError): void => this.observers.forEach(o => o.error?.(e)); /** @@ -107,7 +109,7 @@ export class RealtimeHandler { } if (this.reader) { - this.reader.cancel(); + void this.reader.cancel(); this.reader = undefined; } } @@ -150,7 +152,7 @@ export class RealtimeHandler { const requestBody = { project: this.projectId, namespace: this.namespace, - lastKnownVersionNumber: lastKnownVersionNumber, + lastKnownVersionNumber, appId: this.appId, sdkVersion: this.sdkVersion, appInstanceId: installationId @@ -160,7 +162,7 @@ export class RealtimeHandler { method: 'POST', headers, body: JSON.stringify(requestBody), - signal: signal + signal }); return response; } @@ -181,12 +183,13 @@ export class RealtimeHandler { ]); this.controller = new AbortController(); const url = this.getRealtimeUrl(); - return await this.establishRealtimeConnection( + const realtimeConnection = await this.establishRealtimeConnection( url, installationId, installationTokenResult, this.controller.signal ); + return realtimeConnection; } /** @@ -205,7 +208,7 @@ export class RealtimeHandler { ).getTime(); const currentTime = Date.now(); const retryMillis = Math.max(0, backoffEndTime - currentTime); - this.makeRealtimeHttpConnection(retryMillis); + await this.makeRealtimeHttpConnection(retryMillis); } private setIsHttpConnectionRunning(connectionRunning: boolean): void { @@ -259,7 +262,7 @@ export class RealtimeHandler { } } catch (error) { //there might have been a transient error so the client will retry the connection. - console.error( + this.logger.error( 'Exception connecting to real-time RC backend. Retrying the connection...:', error ); @@ -281,7 +284,7 @@ export class RealtimeHandler { if (connectionFailed || response?.ok) { await this.retryHttpConnectionWhenBackoffEnds(); } else { - let errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`; + const errorMessage = `Unable to connect to the server. HTTP status code: ${responseCode}`; const firebaseError = ERROR_FACTORY.create( ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index c516e83e048..df54439b3f5 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -115,7 +115,8 @@ export function registerRemoteConfig(): void { namespace, projectId, apiKey, - appId + appId, + logger ); const remoteConfigInstance = new RemoteConfigImpl( diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index 2ba0a4adc00..8dd767ef101 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -197,11 +197,11 @@ export abstract class Storage { } setRealtimeBackoffMetadata( - realtime_metadata: RealtimeBackoffMetadata + realtimeMetadata: RealtimeBackoffMetadata ): Promise { return this.set( 'realtime_backoff_metadata', - realtime_metadata + realtimeMetadata ); } } 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" From 0ff48938e16ea83b8770599e3b8b916240587130 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Tue, 12 Aug 2025 10:50:32 +0530 Subject: [PATCH 5/7] Fix yarn failures --- packages/remote-config/package.json | 1 - packages/remote-config/src/client/realtime_handler.ts | 3 +-- yarn.lock | 5 ----- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index dfad010fde4..556302d773c 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -44,7 +44,6 @@ "@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/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index 253e4f80873..f7ab702c533 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -21,7 +21,6 @@ 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'; @@ -243,7 +242,7 @@ export class RealtimeHandler { }; } const backoffEndTime = backoffMetadata.backoffEndTimeMillis.getTime(); - if (isBefore(new Date(), backoffEndTime)) { + if (Date.now() < backoffEndTime) { await this.retryHttpConnectionWhenBackoffEnds(); return; } diff --git a/yarn.lock b/yarn.lock index 0083e9bc0fd..540d7bc7171 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6015,11 +6015,6 @@ 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" From 61ba8151bafa9002be8968da8d4d7b5f8b0d21b5 Mon Sep 17 00:00:00 2001 From: Samruddhi Date: Tue, 12 Aug 2025 16:53:35 +0530 Subject: [PATCH 6/7] Convert backoff time from seconds to minutes --- packages/remote-config/src/client/realtime_handler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index f7ab702c533..e599e841bb0 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -74,7 +74,7 @@ export class RealtimeHandler { const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0) + 1; - const backoffMillis = calculateBackoffMillis(numFailedStreams); + const backoffMillis = calculateBackoffMillis(numFailedStreams) * 60; await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: new Date( lastFailedStreamTime.getTime() + backoffMillis From 8f92245e31bff867627840549d813e55e7bd4694 Mon Sep 17 00:00:00 2001 From: Samruddhi90 <141067188+Samruddhi90@users.noreply.github.com> Date: Mon, 18 Aug 2025 14:14:25 +0530 Subject: [PATCH 7/7] Manage HTTP connections based on tab visibility (#9202) * Added the visibilityMonitor * minor changes * resolving the spacing problem --- .../remote-config/src/client/eventEmitter.ts | 104 ++++++++++++++++++ .../src/client/realtime_handler.ts | 50 +++++++-- .../src/client/visibility_monitor.ts | 86 +++++++++++++++ 3 files changed, 233 insertions(+), 7 deletions(-) create mode 100644 packages/remote-config/src/client/eventEmitter.ts create mode 100644 packages/remote-config/src/client/visibility_monitor.ts diff --git a/packages/remote-config/src/client/eventEmitter.ts b/packages/remote-config/src/client/eventEmitter.ts new file mode 100644 index 00000000000..10e2201ba2b --- /dev/null +++ b/packages/remote-config/src/client/eventEmitter.ts @@ -0,0 +1,104 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from '@firebase/util'; + +// TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config. +/** + * Base class to be used if you want to emit events. Call the constructor with + * the set of allowed event names. + */ +export abstract class EventEmitter { + private listeners_: { + [eventType: string]: Array<{ + callback(...args: unknown[]): void; + context: unknown; + }>; + } = {}; + + constructor(private allowedEvents_: string[]) { + assert( + Array.isArray(allowedEvents_) && allowedEvents_.length > 0, + 'Requires a non-empty array' + ); + } + + /** + * To be overridden by derived classes in order to fire an initial event when + * somebody subscribes for data. + * + * @returns {Array.<*>} Array of parameters to trigger initial event with. + */ + abstract getInitialEvent(eventType: string): unknown[]; + + /** + * To be called by derived classes to trigger events. + */ + protected trigger(eventType: string, ...varArgs: unknown[]): void { + if (Array.isArray(this.listeners_[eventType])) { + // Clone the list, since callbacks could add/remove listeners. + const listeners = [...this.listeners_[eventType]]; + + for (let i = 0; i < listeners.length; i++) { + listeners[i].callback.apply(listeners[i].context, varArgs); + } + } + } + + on( + eventType: string, + callback: (a: unknown) => void, + context: unknown + ): void { + this.validateEventType_(eventType); + this.listeners_[eventType] = this.listeners_[eventType] || []; + this.listeners_[eventType].push({ callback, context }); + + const eventData = this.getInitialEvent(eventType); + if (eventData) { + //@ts-ignore + callback.apply(context, eventData); + } + } + + off( + eventType: string, + callback: (a: unknown) => void, + context: unknown + ): void { + this.validateEventType_(eventType); + const listeners = this.listeners_[eventType] || []; + for (let i = 0; i < listeners.length; i++) { + if ( + listeners[i].callback === callback && + (!context || context === listeners[i].context) + ) { + listeners.splice(i, 1); + return; + } + } + } + + private validateEventType_(eventType: string): void { + assert( + this.allowedEvents_.find(et => { + return et === eventType; + }), + 'Unknown event: ' + eventType + ); + } +} diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index e599e841bb0..8f8f7311d5e 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -21,6 +21,7 @@ import { ConfigUpdateObserver } 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'; const API_KEY_HEADER = 'X-Goog-Api-Key'; const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; @@ -40,6 +41,11 @@ export class RealtimeHandler { private readonly logger: Logger ) { void this.setRetriesRemaining(); + void VisibilityMonitor.getInstance().on( + 'visible', + this.onVisibilityChange, + this + ); } private observers: Set = @@ -49,6 +55,7 @@ export class RealtimeHandler { private controller?: AbortController; private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; + private isInBackground: boolean = false; private async setRetriesRemaining(): Promise { // Retrieve number of remaining retries from last session. The minimum retry count being one. @@ -102,7 +109,7 @@ export class RealtimeHandler { * and canceling the stream reader if they exist. */ private closeRealtimeHttpConnection(): void { - if (this.controller) { + if (this.controller && !this.isInBackground) { this.controller.abort(); this.controller = undefined; } @@ -260,11 +267,18 @@ export class RealtimeHandler { //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 - ); + 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. this.closeRealtimeHttpConnection(); @@ -304,7 +318,13 @@ export class RealtimeHandler { const hasActiveListeners = this.observers.size > 0; const isNotDisabled = !this.isRealtimeDisabled; const isNoConnectionActive = !this.isConnectionActive; - return hasActiveListeners && isNotDisabled && isNoConnectionActive; + const inForeground = !this.isInBackground; + return ( + hasActiveListeners && + isNotDisabled && + isNoConnectionActive && + inForeground + ); } private async makeRealtimeHttpConnection(delayMillis: number): Promise { @@ -316,6 +336,12 @@ export class RealtimeHandler { setTimeout(async () => { await this.beginRealtimeHttpStream(); }, delayMillis); + } 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); } } @@ -343,4 +369,14 @@ export class RealtimeHandler { this.observers.delete(observer); } } + + private async onVisibilityChange(visible: unknown): Promise { + this.isInBackground = !visible; + if (!visible && this.controller) { + this.controller.abort(); + this.controller = undefined; + } else if (visible) { + await this.beginRealtime(); + } + } } diff --git a/packages/remote-config/src/client/visibility_monitor.ts b/packages/remote-config/src/client/visibility_monitor.ts new file mode 100644 index 00000000000..27028e3eeca --- /dev/null +++ b/packages/remote-config/src/client/visibility_monitor.ts @@ -0,0 +1,86 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { assert } from '@firebase/util'; + +import { EventEmitter } from './eventEmitter'; + +declare const document: Document; + +// TODO: Consolidate the Visibility monitoring API code into a shared utility function in firebase/util to be used by both packages/database and packages/remote-config. +export class VisibilityMonitor extends EventEmitter { + private visible_: boolean; + + static getInstance(): VisibilityMonitor { + return new VisibilityMonitor(); + } + + constructor() { + super(['visible']); + let hidden: string; + let visibilityChange: string; + if ( + typeof document !== 'undefined' && + typeof document.addEventListener !== 'undefined' + ) { + if (typeof document['hidden'] !== 'undefined') { + // Opera 12.10 and Firefox 18 and later support + visibilityChange = 'visibilitychange'; + hidden = 'hidden'; + } // @ts-ignore + else if (typeof document['mozHidden'] !== 'undefined') { + visibilityChange = 'mozvisibilitychange'; + hidden = 'mozHidden'; + } // @ts-ignore + else if (typeof document['msHidden'] !== 'undefined') { + visibilityChange = 'msvisibilitychange'; + hidden = 'msHidden'; + } // @ts-ignore + else if (typeof document['webkitHidden'] !== 'undefined') { + visibilityChange = 'webkitvisibilitychange'; + hidden = 'webkitHidden'; + } + } + + // Initially, we always assume we are visible. This ensures that in browsers + // without page visibility support or in cases where we are never visible + // (e.g. chrome extension), we act as if we are visible, i.e. don't delay + // reconnects + this.visible_ = true; + + // @ts-ignore + if (visibilityChange) { + document.addEventListener( + visibilityChange, + () => { + // @ts-ignore + const visible = !document[hidden]; + if (visible !== this.visible_) { + this.visible_ = visible; + this.trigger('visible', visible); + } + }, + false + ); + } + } + + getInitialEvent(eventType: string): boolean[] { + assert(eventType === 'visible', 'Unknown event type: ' + eventType); + return [this.visible_]; + } +}