diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index 1da7c29df0d..de009aa46de 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -42,11 +42,15 @@ export interface FetchResponse { config?: FirebaseRemoteConfigObject; eTag?: string; status: number; + templateVersion?: number; } // @public export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; +// @public +export type FetchType = 'BASE' | 'REALTIME'; + // @public export interface FirebaseRemoteConfigObject { // (undocumented) diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md index 414188e72bb..1955dd47492 100644 --- a/docs-devsite/remote-config.fetchresponse.md +++ b/docs-devsite/remote-config.fetchresponse.md @@ -27,6 +27,7 @@ export interface FetchResponse | [config](./remote-config.fetchresponse.md#fetchresponseconfig) | [FirebaseRemoteConfigObject](./remote-config.firebaseremoteconfigobject.md#firebaseremoteconfigobject_interface) | Defines the map of parameters returned as "entries" in the fetch response body.

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

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

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

Disambiguation: the fetch response returns a legacy "state" value that is redundant with the HTTP status code. The former is normalized into the latter. | +| [templateVersion](./remote-config.fetchresponse.md#fetchresponsetemplateversion) | number | The version number of the config template fetched from the server. | ## FetchResponse.config @@ -65,3 +66,13 @@ The HTTP status, which is useful for differentiating success responses with data ```typescript status: number; ``` + +## FetchResponse.templateVersion + +The version number of the config template fetched from the server. + +Signature: + +```typescript +templateVersion?: number; +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 1b8232588de..6f4b78b397f 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -53,6 +53,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Type Alias | Description | | --- | --- | | [FetchStatus](./remote-config.md#fetchstatus) | Summarizes the outcome of the last attempt to fetch config from the Firebase Remote Config server.

| +| [FetchType](./remote-config.md#fetchtype) | Indicates the type of fetch request. | | [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. | @@ -384,6 +385,18 @@ Summarizes the outcome of the last attempt to fetch config from the Firebase Rem export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; ``` +## FetchType + +Indicates the type of fetch request. + + + +Signature: + +```typescript +export type FetchType = 'BASE' | 'REALTIME'; +``` + ## LogLevel Defines levels of Remote Config logging. diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 47ae2d2af64..85926a47b18 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -68,6 +68,9 @@ export function getRemoteConfig( rc._initializePromise = Promise.all([ rc._storage.setLastSuccessfulFetchResponse(options.initialFetchResponse), rc._storage.setActiveConfigEtag(options.initialFetchResponse?.eTag || ''), + rc._storage.setActiveConfigTemplateVersion( + options.initialFetchResponse.templateVersion || 0 + ), rc._storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()), rc._storageCache.setLastFetchStatus('success'), rc._storageCache.setActiveConfig( @@ -100,6 +103,7 @@ export async function activate(remoteConfig: RemoteConfig): Promise { !lastSuccessfulFetchResponse || !lastSuccessfulFetchResponse.config || !lastSuccessfulFetchResponse.eTag || + !lastSuccessfulFetchResponse.templateVersion || lastSuccessfulFetchResponse.eTag === activeConfigEtag ) { // Either there is no successful fetched config, or is the same as current active @@ -108,7 +112,10 @@ export async function activate(remoteConfig: RemoteConfig): Promise { } await Promise.all([ rc._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config), - rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag) + rc._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag), + rc._storage.setActiveConfigTemplateVersion( + lastSuccessfulFetchResponse.templateVersion + ) ]); return true; } diff --git a/packages/remote-config/src/client/realtime_handler.ts b/packages/remote-config/src/client/realtime_handler.ts index 8f8f7311d5e..236f673608a 100644 --- a/packages/remote-config/src/client/realtime_handler.ts +++ b/packages/remote-config/src/client/realtime_handler.ts @@ -17,17 +17,32 @@ import { _FirebaseInstallationsInternal } from '@firebase/installations'; import { Logger } from '@firebase/logger'; -import { ConfigUpdateObserver } from '../public_types'; +import { + ConfigUpdate, + ConfigUpdateObserver, + FetchResponse, + FirebaseRemoteConfigObject +} from '../public_types'; import { calculateBackoffMillis, FirebaseError } from '@firebase/util'; import { ERROR_FACTORY, ErrorCode } from '../errors'; import { Storage } from '../storage/storage'; import { VisibilityMonitor } from './visibility_monitor'; +import { StorageCache } from '../storage/storage_cache'; +import { + FetchRequest, + RemoteConfigAbortSignal +} from './remote_config_fetch_client'; +import { CachingClient } from './caching_client'; const API_KEY_HEADER = 'X-Goog-Api-Key'; const INSTALLATIONS_AUTH_TOKEN_HEADER = 'X-Goog-Firebase-Installations-Auth'; const ORIGINAL_RETRIES = 8; +const MAXIMUM_FETCH_ATTEMPTS = 3; const NO_BACKOFF_TIME_IN_MILLIS = -1; const NO_FAILED_REALTIME_STREAMS = 0; +const REALTIME_DISABLED_KEY = 'featureDisabled'; +const REALTIME_RETRY_INTERVAL = 'retryIntervalSeconds'; +const TEMPLATE_VERSION_KEY = 'latestTemplateVersionNumber'; export class RealtimeHandler { constructor( @@ -38,7 +53,9 @@ export class RealtimeHandler { private readonly projectId: string, private readonly apiKey: string, private readonly appId: string, - private readonly logger: Logger + private readonly logger: Logger, + private readonly storageCache: StorageCache, + private readonly cachingClient: CachingClient ) { void this.setRetriesRemaining(); void VisibilityMonitor.getInstance().on( @@ -53,9 +70,11 @@ export class RealtimeHandler { private isConnectionActive: boolean = false; private isRealtimeDisabled: boolean = false; private controller?: AbortController; - private reader: ReadableStreamDefaultReader | undefined; + private reader: ReadableStreamDefaultReader | undefined; private httpRetriesRemaining: number = ORIGINAL_RETRIES; private isInBackground: boolean = false; + private readonly decoder = new TextDecoder('utf-8'); + private isClosingConnection: boolean = false; private async setRetriesRemaining(): Promise { // Retrieve number of remaining retries from last session. The minimum retry count being one. @@ -81,7 +100,7 @@ export class RealtimeHandler { const numFailedStreams = ((await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0) + 1; - const backoffMillis = calculateBackoffMillis(numFailedStreams) * 60; + const backoffMillis = calculateBackoffMillis(numFailedStreams, 60000, 2); await this.storage.setRealtimeBackoffMetadata({ backoffEndTimeMillis: new Date( lastFailedStreamTime.getTime() + backoffMillis @@ -90,6 +109,24 @@ export class RealtimeHandler { }); } + /** + * Increase the backoff duration with a new end time based on Retry Interval. + */ + private async updateBackoffMetadataWithRetryInterval( + retryIntervalSeconds: number + ): Promise { + const currentTime = Date.now(); + const backoffDurationInMillis = retryIntervalSeconds * 1000; + const backoffEndTime = new Date(currentTime + backoffDurationInMillis); + const numFailedStreams = + (await this.storage.getRealtimeBackoffMetadata())?.numFailedStreams || 0; + await this.storage.setRealtimeBackoffMetadata({ + backoffEndTimeMillis: backoffEndTime, + numFailedStreams + }); + await this.retryHttpConnectionWhenBackoffEnds(); + } + /** * HTTP status code that the Realtime client should retry on. */ @@ -105,19 +142,34 @@ export class RealtimeHandler { }; /** - * Stops the real-time HTTP connection by aborting the in-progress fetch request - * and canceling the stream reader if they exist. + * Closes the realtime HTTP connection. + * Note: This method is designed to be called only once at a time. + * If a call is already in progress, subsequent calls will be ignored. */ - private closeRealtimeHttpConnection(): void { - if (this.controller && !this.isInBackground) { - this.controller.abort(); - this.controller = undefined; + private async closeRealtimeHttpConnection(): Promise { + if (this.isClosingConnection) { + return; } + this.isClosingConnection = true; - if (this.reader) { - void this.reader.cancel(); + try { + if (this.reader) { + await this.reader.cancel(); + } + } catch (e) { + // The network connection was lost, so cancel() failed. + // This is expected in a disconnected state, so we can safely ignore the error. + this.logger.debug('Failed to cancel the reader, connection is gone.'); + } finally { this.reader = undefined; } + + if (this.controller) { + await this.controller.abort(); + this.controller = undefined; + } + + this.isClosingConnection = false; } private async resetRealtimeBackoff(): Promise { @@ -144,7 +196,7 @@ export class RealtimeHandler { ): Promise { const eTagValue = await this.storage.getActiveConfigEtag(); const lastKnownVersionNumber = - await this.storage.getLastKnownTemplateVersion(); + await this.storage.getActiveConfigTemplateVersion(); const headers = { [API_KEY_HEADER]: this.apiKey, @@ -221,6 +273,11 @@ export class RealtimeHandler { this.isConnectionActive = connectionRunning; } + /** + * Combines the check and set operations to prevent multiple asynchronous + * calls from redundantly starting an HTTP connection. This ensures that + * only one attempt is made at a time. + */ private checkAndSetHttpConnectionFlagIfNotRunning(): boolean { const canMakeConnection = this.canEstablishStreamConnection(); if (canMakeConnection) { @@ -229,6 +286,280 @@ export class RealtimeHandler { return canMakeConnection; } + private fetchResponseIsUpToDate( + fetchResponse: FetchResponse, + lastKnownVersion: number + ): boolean { + // If there is a config, make sure its version is >= the last known version. + if (fetchResponse.config != null && fetchResponse.templateVersion) { + return fetchResponse.templateVersion >= lastKnownVersion; + } + // If there isn't a config, return true if the fetch was successful and backend had no update. + // Else, it returned an out of date config. + return this.storageCache.getLastFetchStatus() === 'success'; + } + + private parseAndValidateConfigUpdateMessage(message: string): string { + const left = message.indexOf('{'); + const right = message.indexOf('}', left); + + if (left < 0 || right < 0) { + return ''; + } + return left >= right ? '' : message.substring(left, right + 1); + } + + private isEventListenersEmpty(): boolean { + return this.observers.size === 0; + } + + private getRandomInt(max: number): number { + return Math.floor(Math.random() * max); + } + + private executeAllListenerCallbacks(configUpdate: ConfigUpdate): void { + this.observers.forEach(observer => observer.next(configUpdate)); + } + + /** + * Compares two configuration objects and returns a set of keys that have changed. + * A key is considered changed if it's new, removed, or has a different value. + */ + private getChangedParams( + newConfig: FirebaseRemoteConfigObject, + oldConfig: FirebaseRemoteConfigObject + ): Set { + const changedKeys = new Set(); + const newKeys = new Set(Object.keys(newConfig || {})); + const oldKeys = new Set(Object.keys(oldConfig || {})); + + for (const key of newKeys) { + if (!oldKeys.has(key) || newConfig[key] !== oldConfig[key]) { + changedKeys.add(key); + } + } + + for (const key of oldKeys) { + if (!newKeys.has(key)) { + changedKeys.add(key); + } + } + + return changedKeys; + } + + private async fetchLatestConfig( + remainingAttempts: number, + targetVersion: number + ): Promise { + const remainingAttemptsAfterFetch = remainingAttempts - 1; + const currentAttempt = MAXIMUM_FETCH_ATTEMPTS - remainingAttemptsAfterFetch; + const customSignals = this.storageCache.getCustomSignals(); + if (customSignals) { + this.logger.debug( + `Fetching config with custom signals: ${JSON.stringify(customSignals)}` + ); + } + const abortSignal = new RemoteConfigAbortSignal(); + try { + const fetchRequest: FetchRequest = { + cacheMaxAgeMillis: 0, + signal: abortSignal, + customSignals, + fetchType: 'REALTIME', + fetchAttempt: currentAttempt + }; + + const fetchResponse: FetchResponse = await this.cachingClient.fetch( + fetchRequest + ); + let activatedConfigs = await this.storage.getActiveConfig(); + + if (!this.fetchResponseIsUpToDate(fetchResponse, targetVersion)) { + this.logger.debug( + "Fetched template version is the same as SDK's current version." + + ' Retrying fetch.' + ); + // Continue fetching until template version number is greater than current. + await this.autoFetch(remainingAttemptsAfterFetch, targetVersion); + return; + } + + if (fetchResponse.config == null) { + this.logger.debug( + 'The fetch succeeded, but the backend had no updates.' + ); + return; + } + + if (activatedConfigs == null) { + activatedConfigs = {}; + } + + const updatedKeys = this.getChangedParams( + fetchResponse.config, + activatedConfigs + ); + + if (updatedKeys.size === 0) { + this.logger.debug('Config was fetched, but no params changed.'); + return; + } + + const configUpdate: ConfigUpdate = { + getUpdatedKeys(): Set { + return new Set(updatedKeys); + } + }; + this.executeAllListenerCallbacks(configUpdate); + } catch (e: unknown) { + const errorMessage = e instanceof Error ? e.message : String(e); + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, { + originalErrorMessage: `Failed to auto-fetch config update: ${errorMessage}` + }); + this.propagateError(error); + } + } + + private async autoFetch( + remainingAttempts: number, + targetVersion: number + ): Promise { + if (remainingAttempts === 0) { + const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_NOT_FETCHED, { + originalErrorMessage: + 'Unable to fetch the latest version of the template.' + }); + this.propagateError(error); + return; + } + + const timeTillFetchSeconds = this.getRandomInt(4); + const timeTillFetchInMiliseconds = timeTillFetchSeconds * 1000; + + await new Promise(resolve => + setTimeout(resolve, timeTillFetchInMiliseconds) + ); + await this.fetchLatestConfig(remainingAttempts, targetVersion); + } + + /** + * Processes a stream of real-time messages for configuration updates. + * This method reassembles fragmented messages, validates and parses the JSON, + * and automatically fetches a new config if a newer template version is available. + * It also handles server-specified retry intervals and propagates errors for + * invalid messages or when real-time updates are disabled. + */ + private async handleNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + if (reader == null) { + return; + } + + let partialConfigUpdateMessage: string; + let currentConfigUpdateMessage = ''; + + while (true) { + const { done, value } = await reader.read(); + if (done) { + break; + } + + partialConfigUpdateMessage = this.decoder.decode(value, { stream: true }); + currentConfigUpdateMessage += partialConfigUpdateMessage; + + if (partialConfigUpdateMessage.includes('}')) { + currentConfigUpdateMessage = this.parseAndValidateConfigUpdateMessage( + currentConfigUpdateMessage + ); + + if (currentConfigUpdateMessage.length === 0) { + continue; + } + + try { + const jsonObject = JSON.parse(currentConfigUpdateMessage); + + if (this.isEventListenersEmpty()) { + break; + } + + if ( + REALTIME_DISABLED_KEY in jsonObject && + jsonObject[REALTIME_DISABLED_KEY] === true + ) { + const error = ERROR_FACTORY.create( + ErrorCode.CONFIG_UPDATE_UNAVAILABLE, + { + originalErrorMessage: + 'The server is temporarily unavailable. Try again in a few minutes.' + } + ); + this.propagateError(error); + break; + } + + if (TEMPLATE_VERSION_KEY in jsonObject) { + const oldTemplateVersion = + await this.storage.getActiveConfigTemplateVersion(); + const targetTemplateVersion = Number( + jsonObject[TEMPLATE_VERSION_KEY] + ); + if ( + oldTemplateVersion && + targetTemplateVersion > oldTemplateVersion + ) { + await this.autoFetch( + MAXIMUM_FETCH_ATTEMPTS, + targetTemplateVersion + ); + } + } + + // This field in the response indicates that the realtime request should retry after the + // specified interval to establish a long-lived connection. This interval extends the + // backoff duration without affecting the number of retries, so it will not enter an + // exponential backoff state. + if (REALTIME_RETRY_INTERVAL in jsonObject) { + const retryIntervalSeconds = Number( + jsonObject[REALTIME_RETRY_INTERVAL] + ); + await this.updateBackoffMetadataWithRetryInterval( + retryIntervalSeconds + ); + } + } catch (e: unknown) { + this.logger.debug('Unable to parse latest config update message.', e); + const errorMessage = e instanceof Error ? e.message : String(e); + this.propagateError( + ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID, { + originalErrorMessage: errorMessage + }) + ); + } + currentConfigUpdateMessage = ''; + } + } + } + + private async listenForNotifications( + reader: ReadableStreamDefaultReader + ): Promise { + try { + await this.handleNotifications(reader); + } catch (e) { + // If the real-time connection is at an unexpected lifecycle state when the app is + // backgrounded, it's expected closing the connection and will throw an exception. + if (!this.isInBackground) { + // Otherwise, the real-time server connection was closed due to a transient issue. + this.logger.debug( + 'Real-time connection was closed due to an exception.' + ); + } + } + } + /** * Open the real-time connection, begin listening for updates, and auto-fetch when an update is * received. @@ -263,8 +594,10 @@ export class RealtimeHandler { if (response.ok && response.body) { this.resetRetryCount(); await this.resetRealtimeBackoff(); - //const configAutoFetch = this.startAutoFetch(reader); - //await configAutoFetch.listenForNotifications(); + const reader = response.body.getReader(); + this.reader = reader; + // Start listening for realtime notifications. + await this.listenForNotifications(reader); } } catch (error) { if (this.isInBackground) { @@ -281,12 +614,13 @@ export class RealtimeHandler { } } finally { // Close HTTP connection and associated streams. - this.closeRealtimeHttpConnection(); + await this.closeRealtimeHttpConnection(); this.setIsHttpConnectionRunning(false); // Update backoff metadata if the connection failed in the foreground. const connectionFailed = - responseCode == null || this.isStatusCodeRetryable(responseCode); + !this.isInBackground && + (responseCode == null || this.isStatusCodeRetryable(responseCode)); if (connectionFailed) { await this.updateBackoffMetadataWithLastFailedStreamConnectionTime( @@ -333,9 +667,8 @@ export class RealtimeHandler { } if (this.httpRetriesRemaining > 0) { this.httpRetriesRemaining--; - setTimeout(async () => { - await this.beginRealtimeHttpStream(); - }, delayMillis); + await new Promise(resolve => setTimeout(resolve, delayMillis)); + await this.beginRealtimeHttpStream(); } else if (!this.isInBackground) { const error = ERROR_FACTORY.create(ErrorCode.CONFIG_UPDATE_STREAM_ERROR, { originalErrorMessage: @@ -370,11 +703,17 @@ export class RealtimeHandler { } } + /** + * Handles changes to the application's visibility state, managing the real-time connection. + * + * When the application is moved to the background, this method closes the existing + * real-time connection to save resources. When the application returns to the + * foreground, it attempts to re-establish the connection. + */ private async onVisibilityChange(visible: unknown): Promise { this.isInBackground = !visible; - if (!visible && this.controller) { - this.controller.abort(); - this.controller = undefined; + if (!visible) { + await this.closeRealtimeHttpConnection(); } else if (visible) { await this.beginRealtime(); } diff --git a/packages/remote-config/src/client/remote_config_fetch_client.ts b/packages/remote-config/src/client/remote_config_fetch_client.ts index 359bb7c0409..02fdbd19283 100644 --- a/packages/remote-config/src/client/remote_config_fetch_client.ts +++ b/packages/remote-config/src/client/remote_config_fetch_client.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { CustomSignals, FetchResponse } from '../public_types'; +import { CustomSignals, FetchResponse, FetchType } from '../public_types'; /** * Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the @@ -100,4 +100,18 @@ export interface FetchRequest { *

Optional in case no custom signals are set for the instance. */ customSignals?: CustomSignals; + + /** + * The type of fetch to perform, such as a regular fetch or a real-time fetch. + * + *

Optional as not all fetch requests need to be distinguished. + */ + fetchType?: FetchType; + + /** + * The number of fetch attempts made so far for this request. + * + *

Optional as not all fetch requests are part of a retry series. + */ + fetchAttempt?: number; } diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 57f55f53d88..542b2ad7ead 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -82,12 +82,16 @@ export class RestClient implements RemoteConfigFetchClient { const url = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:fetch?key=${this.apiKey}`; + const fetchType = request.fetchType || 'BASE'; + const fetchAttempt = request.fetchAttempt || 0; + const headers = { 'Content-Type': 'application/json', 'Content-Encoding': 'gzip', // Deviates from pure decorator by not passing max-age header since we don't currently have // service behavior using that header. - 'If-None-Match': request.eTag || '*' + 'If-None-Match': request.eTag || '*', + 'X_FIREBASE_RC_FETCH_TYPE': `${fetchType}/${fetchAttempt}` }; const requestBody: FetchRequestBody = { @@ -140,6 +144,7 @@ export class RestClient implements RemoteConfigFetchClient { let config: FirebaseRemoteConfigObject | undefined; let state: string | undefined; + let templateVersion: number | undefined; // JSON parsing throws SyntaxError if the response body isn't a JSON string. // Requesting application/json and checking for a 200 ensures there's JSON data. @@ -154,6 +159,7 @@ export class RestClient implements RemoteConfigFetchClient { } config = responseBody['entries']; state = responseBody['state']; + templateVersion = responseBody['templateVersion']; } // Normalizes based on legacy state. @@ -176,6 +182,6 @@ export class RestClient implements RemoteConfigFetchClient { }); } - return { status, eTag: responseEtag, config }; + return { status, eTag: responseEtag, config, templateVersion }; } } diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index ac7c71b3218..d12bd766c55 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -34,7 +34,10 @@ export const enum ErrorCode { FETCH_STATUS = 'fetch-status', INDEXED_DB_UNAVAILABLE = 'indexed-db-unavailable', CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS = 'custom-signal-max-allowed-signals', - CONFIG_UPDATE_STREAM_ERROR = 'stream-error' + CONFIG_UPDATE_STREAM_ERROR = 'stream-error', + CONFIG_UPDATE_UNAVAILABLE = 'realtime-unavailable', + CONFIG_UPDATE_MESSAGE_INVALID = 'update-message-invalid', + CONFIG_UPDATE_NOT_FETCHED = 'update-not-fetched' } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { @@ -75,7 +78,11 @@ 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.', + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: 'The Realtime service is unavailable.', + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: + 'The stream invalidation message was unparsable.', + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: 'Unable to fetch the latest config.' }; // Note this is effectively a type system binding a code to params. This approach overlaps with the @@ -99,6 +106,9 @@ interface ErrorParams { httpStatus?: number; originalErrorMessage?: string; }; + [ErrorCode.CONFIG_UPDATE_UNAVAILABLE]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_MESSAGE_INVALID]: { originalErrorMessage: string }; + [ErrorCode.CONFIG_UPDATE_NOT_FETCHED]: { originalErrorMessage: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index e65b9557b9e..964726a51f4 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -52,6 +52,8 @@ export interface RemoteConfig { /** * Defines a self-descriptive reference for config key-value pairs. + * + * @public */ export interface FirebaseRemoteConfigObject { [key: string]: string; @@ -62,6 +64,8 @@ export interface FirebaseRemoteConfigObject { * *

Modeled after the native `Response` interface, but simplified for Remote Config's * use case. + * + * @public */ export interface FetchResponse { /** @@ -90,6 +94,11 @@ export interface FetchResponse { */ config?: FirebaseRemoteConfigObject; + /** + * The version number of the config template fetched from the server. + */ + templateVersion?: number; + // Note: we're not extracting experiment metadata until // ABT and Analytics have Web SDKs. } @@ -257,6 +266,18 @@ export interface ConfigUpdateObserver { */ export type Unsubscribe = () => void; +/** + * Indicates the type of fetch request. + * + *

    + *
  • "BASE" indicates a standard fetch request.
  • + *
  • "REALTIME" indicates a fetch request triggered by a real-time update.
  • + *
+ * + * @public + */ +export type FetchType = 'BASE' | 'REALTIME'; + declare module '@firebase/component' { interface NameServiceMapping { 'remote-config': RemoteConfig; diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index df54439b3f5..9c25d01831a 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -116,7 +116,9 @@ export function registerRemoteConfig(): void { projectId, apiKey, appId, - logger + logger, + storageCache, + cachingClient ); const remoteConfigInstance = new RemoteConfigImpl( diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index 8dd767ef101..bd262d29968 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -192,10 +192,6 @@ export abstract class Storage { return this.get('realtime_backoff_metadata'); } - getLastKnownTemplateVersion(): Promise { - return this.get('last_known_template_version'); - } - setRealtimeBackoffMetadata( realtimeMetadata: RealtimeBackoffMetadata ): Promise { @@ -204,6 +200,14 @@ export abstract class Storage { realtimeMetadata ); } + + getActiveConfigTemplateVersion(): Promise { + return this.get('last_known_template_version'); + } + + setActiveConfigTemplateVersion(version: number): Promise { + return this.set('last_known_template_version', version); + } } export class IndexedDbStorage extends Storage { diff --git a/packages/remote-config/test/api.test.ts b/packages/remote-config/test/api.test.ts index b1fe658ebae..c6918ac5320 100644 --- a/packages/remote-config/test/api.test.ts +++ b/packages/remote-config/test/api.test.ts @@ -57,7 +57,8 @@ describe('Remote Config API', () => { const STUB_FETCH_RESPONSE: FetchResponse = { status: 200, eTag: 'asdf', - config: { 'foobar': 'hello world' } + config: { 'foobar': 'hello world' }, + templateVersion: 1 }; let fetchStub: sinon.SinonStub; @@ -94,7 +95,8 @@ describe('Remote Config API', () => { json: () => Promise.resolve({ entries: response.config, - state: 'OK' + state: 'OK', + templateVersion: response.templateVersion }) } as Response) ); diff --git a/packages/remote-config/test/client/rest_client.test.ts b/packages/remote-config/test/client/rest_client.test.ts index 96a6cde8454..bda6fbce01a 100644 --- a/packages/remote-config/test/client/rest_client.test.ts +++ b/packages/remote-config/test/client/rest_client.test.ts @@ -26,6 +26,7 @@ import { FetchRequest, RemoteConfigAbortSignal } from '../../src/client/remote_config_fetch_client'; +import { Storage } from '../../src/storage/storage'; const DEFAULT_REQUEST: FetchRequest = { cacheMaxAgeMillis: 1, @@ -34,6 +35,7 @@ const DEFAULT_REQUEST: FetchRequest = { describe('RestClient', () => { const firebaseInstallations = {} as FirebaseInstallations; + const storage = {} as Storage; let client: RestClient; beforeEach(() => { @@ -51,6 +53,7 @@ describe('RestClient', () => { firebaseInstallations.getToken = sinon .stub() .returns(Promise.resolve('fis-token')); + storage.setActiveConfigTemplateVersion = sinon.stub(); }); describe('fetch', () => { @@ -74,7 +77,8 @@ describe('RestClient', () => { status: 200, eTag: 'etag', state: 'UPDATE', - entries: { color: 'sparkling' } + entries: { color: 'sparkling' }, + templateVersion: 1 }; fetchStub.returns( @@ -85,7 +89,8 @@ describe('RestClient', () => { json: () => Promise.resolve({ entries: expectedResponse.entries, - state: expectedResponse.state + state: expectedResponse.state, + templateVersion: expectedResponse.templateVersion }) } as Response) ); @@ -95,7 +100,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: expectedResponse.status, eTag: expectedResponse.eTag, - config: expectedResponse.entries + config: expectedResponse.entries, + templateVersion: expectedResponse.templateVersion }); }); @@ -184,7 +190,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'response-etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -222,7 +229,8 @@ describe('RestClient', () => { expect(response).to.deep.eq({ status: 304, eTag: 'etag', - config: undefined + config: undefined, + templateVersion: undefined }); }); @@ -239,7 +247,8 @@ describe('RestClient', () => { await expect(client.fetch(DEFAULT_REQUEST)).to.eventually.be.deep.eq({ status: 200, eTag: 'etag', - config: {} + config: {}, + templateVersion: undefined }); } }); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 2ee9e71eccf..2c281852093 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -390,39 +390,58 @@ describe('RemoteConfig', () => { const ETAG = 'etag'; const CONFIG = { key: 'val' }; const NEW_ETAG = 'new_etag'; + const TEMPLATE_VERSION = 1; let getLastSuccessfulFetchResponseStub: sinon.SinonStub; let getActiveConfigEtagStub: sinon.SinonStub; + let getActiveConfigTemplateVersionStub: sinon.SinonStub; let setActiveConfigEtagStub: sinon.SinonStub; let setActiveConfigStub: sinon.SinonStub; + let setActiveConfigTemplateVersionStub: sinon.SinonStub; beforeEach(() => { getLastSuccessfulFetchResponseStub = sinon.stub(); getActiveConfigEtagStub = sinon.stub(); + getActiveConfigTemplateVersionStub = sinon.stub(); setActiveConfigEtagStub = sinon.stub(); setActiveConfigStub = sinon.stub(); + setActiveConfigTemplateVersionStub = sinon.stub(); storage.getLastSuccessfulFetchResponse = getLastSuccessfulFetchResponseStub; storage.getActiveConfigEtag = getActiveConfigEtagStub; + storage.getActiveConfigTemplateVersion = + getActiveConfigTemplateVersionStub; storage.setActiveConfigEtag = setActiveConfigEtagStub; storageCache.setActiveConfig = setActiveConfigStub; + storage.setActiveConfigTemplateVersion = + setActiveConfigTemplateVersionStub; }); it('does not activate if last successful fetch response is undefined', async () => { getLastSuccessfulFetchResponseStub.returns(Promise.resolve()); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); + getActiveConfigTemplateVersionStub.returns( + Promise.resolve(TEMPLATE_VERSION) + ); const activateResponse = await activate(rc); expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect( + storage.setActiveConfigTemplateVersion + ).to.not.have.been.calledWith(TEMPLATE_VERSION); }); it('does not activate if fetched and active etags are the same', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: {}, etag: ETAG }) + Promise.resolve({ + config: {}, + eTag: ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -431,11 +450,18 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.false; expect(storage.setActiveConfigEtag).to.not.have.been.called; expect(storageCache.setActiveConfig).to.not.have.been.called; + expect( + storage.setActiveConfigTemplateVersion + ).to.not.have.been.calledWith(TEMPLATE_VERSION); }); it('activates if fetched and active etags are different', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve(ETAG)); @@ -444,11 +470,18 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); it('activates if fetched is defined but active config is not', async () => { getLastSuccessfulFetchResponseStub.returns( - Promise.resolve({ config: CONFIG, eTag: NEW_ETAG }) + Promise.resolve({ + config: CONFIG, + eTag: NEW_ETAG, + templateVersion: TEMPLATE_VERSION + }) ); getActiveConfigEtagStub.returns(Promise.resolve()); @@ -457,6 +490,9 @@ describe('RemoteConfig', () => { expect(activateResponse).to.be.true; expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG); expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG); + expect(storage.setActiveConfigTemplateVersion).to.have.been.calledWith( + TEMPLATE_VERSION + ); }); });