diff --git a/.changeset/flat-plums-hope.md b/.changeset/flat-plums-hope.md new file mode 100644 index 00000000000..ac71c7269aa --- /dev/null +++ b/.changeset/flat-plums-hope.md @@ -0,0 +1,6 @@ +--- +'@firebase/remote-config': minor +'firebase': minor +--- + +Adds support for initial state hydration (from SSR contexts) diff --git a/common/api-review/remote-config.api.md b/common/api-review/remote-config.api.md index bf6cf4761de..213335929dd 100644 --- a/common/api-review/remote-config.api.md +++ b/common/api-review/remote-config.api.md @@ -24,9 +24,22 @@ export function fetchAndActivate(remoteConfig: RemoteConfig): Promise; // @public export function fetchConfig(remoteConfig: RemoteConfig): Promise; +// @public +export interface FetchResponse { + config?: FirebaseRemoteConfigObject; + eTag?: string; + status: number; +} + // @public export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; +// @public +export interface FirebaseRemoteConfigObject { + // (undocumented) + [key: string]: string; +} + // @public export function getAll(remoteConfig: RemoteConfig): Record; @@ -37,7 +50,7 @@ export function getBoolean(remoteConfig: RemoteConfig, key: string): boolean; export function getNumber(remoteConfig: RemoteConfig, key: string): number; // @public (undocumented) -export function getRemoteConfig(app?: FirebaseApp): RemoteConfig; +export function getRemoteConfig(app?: FirebaseApp, options?: RemoteConfigOptions): RemoteConfig; // @public export function getString(remoteConfig: RemoteConfig, key: string): string; @@ -62,6 +75,12 @@ export interface RemoteConfig { settings: RemoteConfigSettings; } +// @public +export interface RemoteConfigOptions { + initialFetchResponse?: FetchResponse; + templateId?: string; +} + // @public export interface RemoteConfigSettings { fetchTimeoutMillis: number; diff --git a/docs-devsite/_toc.yaml b/docs-devsite/_toc.yaml index 4ab67bcd6ef..bf0318389ba 100644 --- a/docs-devsite/_toc.yaml +++ b/docs-devsite/_toc.yaml @@ -430,8 +430,14 @@ toc: section: - title: CustomSignals path: /docs/reference/js/remote-config.customsignals.md + - title: FetchResponse + path: /docs/reference/js/remote-config.fetchresponse.md + - title: FirebaseRemoteConfigObject + path: /docs/reference/js/remote-config.firebaseremoteconfigobject.md - title: RemoteConfig path: /docs/reference/js/remote-config.remoteconfig.md + - title: RemoteConfigOptions + path: /docs/reference/js/remote-config.remoteconfigoptions.md - title: RemoteConfigSettings path: /docs/reference/js/remote-config.remoteconfigsettings.md - title: Value diff --git a/docs-devsite/remote-config.fetchresponse.md b/docs-devsite/remote-config.fetchresponse.md new file mode 100644 index 00000000000..414188e72bb --- /dev/null +++ b/docs-devsite/remote-config.fetchresponse.md @@ -0,0 +1,67 @@ +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 %} + +# FetchResponse interface +Defines a successful response (200 or 304). + +

Modeled after the native `Response` interface, but simplified for Remote Config's use case. + +Signature: + +```typescript +export interface FetchResponse +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [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. | + +## FetchResponse.config + +Defines the map of parameters returned as "entries" in the fetch response body. + +

Only defined for 200 responses. + +Signature: + +```typescript +config?: FirebaseRemoteConfigObject; +``` + +## FetchResponse.eTag + +Defines the ETag response header value. + +

Only defined for 200 and 304 responses. + +Signature: + +```typescript +eTag?: string; +``` + +## FetchResponse.status + +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. + +Signature: + +```typescript +status: number; +``` diff --git a/docs-devsite/remote-config.firebaseremoteconfigobject.md b/docs-devsite/remote-config.firebaseremoteconfigobject.md new file mode 100644 index 00000000000..e7d89e5ec56 --- /dev/null +++ b/docs-devsite/remote-config.firebaseremoteconfigobject.md @@ -0,0 +1,19 @@ +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 %} + +# FirebaseRemoteConfigObject interface +Defines a self-descriptive reference for config key-value pairs. + +Signature: + +```typescript +export interface FirebaseRemoteConfigObject +``` diff --git a/docs-devsite/remote-config.md b/docs-devsite/remote-config.md index 40319453a3f..58d23cfd647 100644 --- a/docs-devsite/remote-config.md +++ b/docs-devsite/remote-config.md @@ -17,7 +17,7 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Function | Description | | --- | --- | | function(app, ...) | -| [getRemoteConfig(app)](./remote-config.md#getremoteconfig_cf608e1) | | +| [getRemoteConfig(app, options)](./remote-config.md#getremoteconfig_61d368f) | | | function(remoteConfig, ...) | | [activate(remoteConfig)](./remote-config.md#activate_722a192) | Makes the last fetched config available to the getters. | | [ensureInitialized(remoteConfig)](./remote-config.md#ensureinitialized_722a192) | Ensures the last activated config are available to the getters. | @@ -38,7 +38,10 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm | Interface | Description | | --- | --- | | [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. | | [RemoteConfig](./remote-config.remoteconfig.md#remoteconfig_interface) | The Firebase Remote Config service interface. | +| [RemoteConfigOptions](./remote-config.remoteconfigoptions.md#remoteconfigoptions_interface) | Options for Remote Config initialization. | | [RemoteConfigSettings](./remote-config.remoteconfigsettings.md#remoteconfigsettings_interface) | Defines configuration options for the Remote Config SDK. | | [Value](./remote-config.value.md#value_interface) | Wraps a value with metadata and type-safe getters. | @@ -52,12 +55,12 @@ The Firebase Remote Config Web SDK. This SDK does not work in a Node.js environm ## function(app, ...) -### getRemoteConfig(app) {:#getremoteconfig_cf608e1} +### getRemoteConfig(app, options) {:#getremoteconfig_61d368f} Signature: ```typescript -export declare function getRemoteConfig(app?: FirebaseApp): RemoteConfig; +export declare function getRemoteConfig(app?: FirebaseApp, options?: RemoteConfigOptions): RemoteConfig; ``` #### Parameters @@ -65,6 +68,7 @@ export declare function getRemoteConfig(app?: FirebaseApp): RemoteConfig; | Parameter | Type | Description | | --- | --- | --- | | app | [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) | The [FirebaseApp](./app.firebaseapp.md#firebaseapp_interface) instance. | +| options | [RemoteConfigOptions](./remote-config.remoteconfigoptions.md#remoteconfigoptions_interface) | Optional. The [RemoteConfigOptions](./remote-config.remoteconfigoptions.md#remoteconfigoptions_interface) with which to instantiate the Remote Config instance. | Returns: diff --git a/docs-devsite/remote-config.remoteconfigoptions.md b/docs-devsite/remote-config.remoteconfigoptions.md new file mode 100644 index 00000000000..7caa96fa73b --- /dev/null +++ b/docs-devsite/remote-config.remoteconfigoptions.md @@ -0,0 +1,46 @@ +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 %} + +# RemoteConfigOptions interface +Options for Remote Config initialization. + +Signature: + +```typescript +export interface RemoteConfigOptions +``` + +## Properties + +| Property | Type | Description | +| --- | --- | --- | +| [initialFetchResponse](./remote-config.remoteconfigoptions.md#remoteconfigoptionsinitialfetchresponse) | [FetchResponse](./remote-config.fetchresponse.md#fetchresponse_interface) | Hydrates the state with an initial fetch response. | +| [templateId](./remote-config.remoteconfigoptions.md#remoteconfigoptionstemplateid) | string | The ID of the template to use. If not provided, defaults to "firebase". | + +## RemoteConfigOptions.initialFetchResponse + +Hydrates the state with an initial fetch response. + +Signature: + +```typescript +initialFetchResponse?: FetchResponse; +``` + +## RemoteConfigOptions.templateId + +The ID of the template to use. If not provided, defaults to "firebase". + +Signature: + +```typescript +templateId?: string; +``` diff --git a/packages/remote-config/src/api.ts b/packages/remote-config/src/api.ts index 607d4944d26..1431864edd5 100644 --- a/packages/remote-config/src/api.ts +++ b/packages/remote-config/src/api.ts @@ -16,11 +16,13 @@ */ import { _getProvider, FirebaseApp, getApp } from '@firebase/app'; +import { deepEqual, getModularInstance } from '@firebase/util'; import { CustomSignals, LogLevel as RemoteConfigLogLevel, RemoteConfig, - Value + Value, + RemoteConfigOptions } from './public_types'; import { RemoteConfigAbortSignal } from './client/remote_config_fetch_client'; import { @@ -28,23 +30,54 @@ import { RC_CUSTOM_SIGNAL_KEY_MAX_LENGTH, RC_CUSTOM_SIGNAL_VALUE_MAX_LENGTH } from './constants'; -import { ErrorCode, hasErrorCode } from './errors'; +import { ERROR_FACTORY, ErrorCode, hasErrorCode } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; import { Value as ValueImpl } from './value'; import { LogLevel as FirebaseLogLevel } from '@firebase/logger'; -import { getModularInstance } from '@firebase/util'; /** * * @param app - The {@link @firebase/app#FirebaseApp} instance. + * @param options - Optional. The {@link RemoteConfigOptions} with which to instantiate the + * Remote Config instance. * @returns A {@link RemoteConfig} instance. * * @public */ -export function getRemoteConfig(app: FirebaseApp = getApp()): RemoteConfig { +export function getRemoteConfig( + app: FirebaseApp = getApp(), + options: RemoteConfigOptions = {} +): RemoteConfig { app = getModularInstance(app); const rcProvider = _getProvider(app, RC_COMPONENT_NAME); - return rcProvider.getImmediate(); + if (rcProvider.isInitialized()) { + const initialOptions = rcProvider.getOptions() as RemoteConfigOptions; + if (deepEqual(initialOptions, options)) { + return rcProvider.getImmediate(); + } + throw ERROR_FACTORY.create(ErrorCode.ALREADY_INITIALIZED); + } + rcProvider.initialize({ options }); + const rc = rcProvider.getImmediate() as RemoteConfigImpl; + + if (options.initialFetchResponse) { + // We use these initial writes as the initialization promise since they will hydrate the same + // fields that `storageCache.loadFromStorage` would set. + rc._initializePromise = Promise.all([ + rc._storage.setLastSuccessfulFetchResponse(options.initialFetchResponse), + rc._storage.setActiveConfigEtag(options.initialFetchResponse?.eTag || ''), + rc._storageCache.setLastSuccessfulFetchTimestampMillis(Date.now()), + rc._storageCache.setLastFetchStatus('success'), + rc._storageCache.setActiveConfig( + options.initialFetchResponse?.config || {} + ) + ]).then(); + // The `storageCache` methods above set their in-memory fields synchronously, so it's + // safe to declare our initialization complete at this point. + rc._isInitializationComplete = true; + } + + return rc; } /** diff --git a/packages/remote-config/src/client/caching_client.ts b/packages/remote-config/src/client/caching_client.ts index c9de804d7e8..2c7009c54be 100644 --- a/packages/remote-config/src/client/caching_client.ts +++ b/packages/remote-config/src/client/caching_client.ts @@ -16,8 +16,8 @@ */ import { StorageCache } from '../storage/storage_cache'; +import { FetchResponse } from '../public_types'; import { - FetchResponse, RemoteConfigFetchClient, FetchRequest } from './remote_config_fetch_client'; 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 71ea66d5e50..359bb7c0409 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 } from '../public_types'; +import { CustomSignals, FetchResponse } from '../public_types'; /** * Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the @@ -36,13 +36,6 @@ export interface RemoteConfigFetchClient { fetch(request: FetchRequest): Promise; } -/** - * Defines a self-descriptive reference for config key-value pairs. - */ -export interface FirebaseRemoteConfigObject { - [key: string]: string; -} - /** * Shims a minimal AbortSignal. * @@ -108,40 +101,3 @@ export interface FetchRequest { */ customSignals?: CustomSignals; } - -/** - * Defines a successful response (200 or 304). - * - *

Modeled after the native {@link Response} interface, but simplified for Remote Config's - * use case. - */ -export interface FetchResponse { - /** - * The HTTP status, which is useful for differentiating success responses with data from - * those without. - * - *

{@link RemoteConfigClient} is modeled after the native {@link GlobalFetch} 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. - */ - status: number; - - /** - * Defines the ETag response header value. - * - *

Only defined for 200 and 304 responses. - */ - eTag?: string; - - /** - * Defines the map of parameters returned as "entries" in the fetch response body. - * - *

Only defined for 200 responses. - */ - config?: FirebaseRemoteConfigObject; - - // Note: we're not extracting experiment metadata until - // ABT and Analytics have Web SDKs. -} diff --git a/packages/remote-config/src/client/rest_client.ts b/packages/remote-config/src/client/rest_client.ts index 9d87ffbb1ac..57f55f53d88 100644 --- a/packages/remote-config/src/client/rest_client.ts +++ b/packages/remote-config/src/client/rest_client.ts @@ -15,11 +15,13 @@ * limitations under the License. */ -import { CustomSignals } from '../public_types'; import { + CustomSignals, FetchResponse, + FirebaseRemoteConfigObject +} from '../public_types'; +import { RemoteConfigFetchClient, - FirebaseRemoteConfigObject, FetchRequest } from './remote_config_fetch_client'; import { ERROR_FACTORY, ErrorCode } from '../errors'; diff --git a/packages/remote-config/src/client/retrying_client.ts b/packages/remote-config/src/client/retrying_client.ts index 874a37bd8f4..ea5bc6e2fe1 100644 --- a/packages/remote-config/src/client/retrying_client.ts +++ b/packages/remote-config/src/client/retrying_client.ts @@ -15,10 +15,10 @@ * limitations under the License. */ +import { FetchResponse } from '../public_types'; import { RemoteConfigAbortSignal, RemoteConfigFetchClient, - FetchResponse, FetchRequest } from './remote_config_fetch_client'; import { ThrottleMetadata, Storage } from '../storage/storage'; diff --git a/packages/remote-config/src/errors.ts b/packages/remote-config/src/errors.ts index 762eeb899ee..446bd2c6e7a 100644 --- a/packages/remote-config/src/errors.ts +++ b/packages/remote-config/src/errors.ts @@ -18,6 +18,7 @@ import { ErrorFactory, FirebaseError } from '@firebase/util'; export const enum ErrorCode { + ALREADY_INITIALIZED = 'already-initialized', REGISTRATION_WINDOW = 'registration-window', REGISTRATION_PROJECT_ID = 'registration-project-id', REGISTRATION_API_KEY = 'registration-api-key', @@ -36,6 +37,7 @@ export const enum ErrorCode { } const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { + [ErrorCode.ALREADY_INITIALIZED]: 'Remote Config already initialized', [ErrorCode.REGISTRATION_WINDOW]: 'Undefined window object. This SDK only supports usage in a browser environment.', [ErrorCode.REGISTRATION_PROJECT_ID]: diff --git a/packages/remote-config/src/public_types.ts b/packages/remote-config/src/public_types.ts index 365d5e5905f..927bc84ca10 100644 --- a/packages/remote-config/src/public_types.ts +++ b/packages/remote-config/src/public_types.ts @@ -50,6 +50,67 @@ export interface RemoteConfig { lastFetchStatus: FetchStatus; } +/** + * Defines a self-descriptive reference for config key-value pairs. + */ +export interface FirebaseRemoteConfigObject { + [key: string]: string; +} + +/** + * Defines a successful response (200 or 304). + * + *

Modeled after the native `Response` interface, but simplified for Remote Config's + * use case. + */ +export interface FetchResponse { + /** + * 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. + */ + status: number; + + /** + * Defines the ETag response header value. + * + *

Only defined for 200 and 304 responses. + */ + eTag?: string; + + /** + * Defines the map of parameters returned as "entries" in the fetch response body. + * + *

Only defined for 200 responses. + */ + config?: FirebaseRemoteConfigObject; + + // Note: we're not extracting experiment metadata until + // ABT and Analytics have Web SDKs. +} + +/** + * Options for Remote Config initialization. + * + * @public + */ +export interface RemoteConfigOptions { + /** + * The ID of the template to use. If not provided, defaults to "firebase". + */ + templateId?: string; + + /** + * Hydrates the state with an initial fetch response. + */ + initialFetchResponse?: FetchResponse; +} + /** * Indicates the source of a value. * diff --git a/packages/remote-config/src/register.ts b/packages/remote-config/src/register.ts index ff83e761888..dda6cc544de 100644 --- a/packages/remote-config/src/register.ts +++ b/packages/remote-config/src/register.ts @@ -23,11 +23,10 @@ import { isIndexedDBAvailable } from '@firebase/util'; import { Component, ComponentType, - ComponentContainer, - InstanceFactoryOptions + ComponentContainer } from '@firebase/component'; import { Logger, LogLevel as FirebaseLogLevel } from '@firebase/logger'; -import { RemoteConfig } from './public_types'; +import { RemoteConfig, RemoteConfigOptions } from './public_types'; import { name as packageName, version } from '../package.json'; import { ensureInitialized } from './api'; import { CachingClient } from './client/caching_client'; @@ -36,7 +35,7 @@ import { RetryingClient } from './client/retrying_client'; import { RC_COMPONENT_NAME } from './constants'; import { ErrorCode, ERROR_FACTORY } from './errors'; import { RemoteConfig as RemoteConfigImpl } from './remote_config'; -import { Storage } from './storage/storage'; +import { IndexedDbStorage, InMemoryStorage } from './storage/storage'; import { StorageCache } from './storage/storage_cache'; // This needs to be in the same file that calls `getProvider()` on the component // or it will get tree-shaken out. @@ -57,7 +56,7 @@ export function registerRemoteConfig(): void { function remoteConfigFactory( container: ComponentContainer, - { instanceIdentifier: namespace }: InstanceFactoryOptions + { options }: { options?: RemoteConfigOptions } ): RemoteConfig { /* Dependencies */ // getImmediate for FirebaseApp will always succeed @@ -67,14 +66,6 @@ export function registerRemoteConfig(): void { .getProvider('installations-internal') .getImmediate(); - // Guards against the SDK being used in non-browser environments. - if (typeof window === 'undefined') { - throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_WINDOW); - } - // Guards against the SDK being used when indexedDB is not available. - if (!isIndexedDBAvailable()) { - throw ERROR_FACTORY.create(ErrorCode.INDEXED_DB_UNAVAILABLE); - } // Normalizes optional inputs. const { projectId, apiKey, appId } = app.options; if (!projectId) { @@ -86,9 +77,11 @@ export function registerRemoteConfig(): void { if (!appId) { throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_APP_ID); } - namespace = namespace || 'firebase'; + const namespace = options?.templateId || 'firebase'; - const storage = new Storage(appId, app.name, namespace); + const storage = isIndexedDBAvailable() + ? new IndexedDbStorage(appId, app.name, namespace) + : new InMemoryStorage(); const storageCache = new StorageCache(storage); const logger = new Logger(packageName); diff --git a/packages/remote-config/src/storage/storage.ts b/packages/remote-config/src/storage/storage.ts index 52e660f1fdb..f03ff41377b 100644 --- a/packages/remote-config/src/storage/storage.ts +++ b/packages/remote-config/src/storage/storage.ts @@ -16,10 +16,7 @@ */ import { FetchStatus, CustomSignals } from '@firebase/remote-config-types'; -import { - FetchResponse, - FirebaseRemoteConfigObject -} from '../client/remote_config_fetch_client'; +import { FetchResponse, FirebaseRemoteConfigObject } from '../public_types'; import { ERROR_FACTORY, ErrorCode } from '../errors'; import { RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS } from '../constants'; import { FirebaseError } from '@firebase/util'; @@ -113,19 +110,7 @@ export function openDatabase(): Promise { /** * Abstracts data persistence. */ -export class Storage { - /** - * @param appId enables storage segmentation by app (ID + name). - * @param appName enables storage segmentation by app (ID + name). - * @param namespace enables storage segmentation by namespace. - */ - constructor( - private readonly appId: string, - private readonly appName: string, - private readonly namespace: string, - private readonly openDbPromise = openDatabase() - ) {} - +export abstract class Storage { getLastFetchStatus(): Promise { return this.get('last_fetch_status'); } @@ -187,6 +172,29 @@ export class Storage { return this.get('custom_signals'); } + abstract setCustomSignals( + customSignals: CustomSignals + ): Promise; + abstract get(key: ProjectNamespaceKeyFieldValue): Promise; + abstract set(key: ProjectNamespaceKeyFieldValue, value: T): Promise; + abstract delete(key: ProjectNamespaceKeyFieldValue): Promise; +} + +export class IndexedDbStorage extends Storage { + /** + * @param appId enables storage segmentation by app (ID + name). + * @param appName enables storage segmentation by app (ID + name). + * @param namespace enables storage segmentation by namespace. + */ + constructor( + private readonly appId: string, + private readonly appName: string, + private readonly namespace: string, + private readonly openDbPromise = openDatabase() + ) { + super(); + } + async setCustomSignals(customSignals: CustomSignals): Promise { const db = await this.openDbPromise; const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite'); @@ -194,33 +202,10 @@ export class Storage { 'custom_signals', transaction ); - const combinedSignals = { - ...storedSignals, - ...customSignals - }; - // Filter out key-value assignments with null values since they are signals being unset - const updatedSignals = Object.fromEntries( - Object.entries(combinedSignals) - .filter(([_, v]) => v !== null) - .map(([k, v]) => { - // Stringify numbers to store a map of string keys and values which can be sent - // as-is in a fetch call. - if (typeof v === 'number') { - return [k, v.toString()]; - } - return [k, v]; - }) + const updatedSignals = mergeCustomSignals( + customSignals, + storedSignals || {} ); - - // Throw an error if the number of custom signals to be stored exceeds the limit - if ( - Object.keys(updatedSignals).length > RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS - ) { - throw ERROR_FACTORY.create(ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS, { - maxSignals: RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS - }); - } - await this.setWithTransaction( 'custom_signals', updatedSignals, @@ -344,3 +329,65 @@ export class Storage { return [this.appId, this.appName, this.namespace, key].join(); } } + +export class InMemoryStorage extends Storage { + private storage: { [key: string]: unknown } = {}; + + async get(key: ProjectNamespaceKeyFieldValue): Promise { + return Promise.resolve(this.storage[key] as T); + } + + async set(key: ProjectNamespaceKeyFieldValue, value: T): Promise { + this.storage[key] = value; + return Promise.resolve(undefined); + } + + async delete(key: ProjectNamespaceKeyFieldValue): Promise { + this.storage[key] = undefined; + return Promise.resolve(); + } + + async setCustomSignals(customSignals: CustomSignals): Promise { + const storedSignals = (this.storage['custom_signals'] || + {}) as CustomSignals; + this.storage['custom_signals'] = mergeCustomSignals( + customSignals, + storedSignals + ); + return Promise.resolve(this.storage['custom_signals'] as CustomSignals); + } +} + +function mergeCustomSignals( + customSignals: CustomSignals, + storedSignals: CustomSignals +): CustomSignals { + const combinedSignals = { + ...storedSignals, + ...customSignals + }; + + // Filter out key-value assignments with null values since they are signals being unset + const updatedSignals = Object.fromEntries( + Object.entries(combinedSignals) + .filter(([_, v]) => v !== null) + .map(([k, v]) => { + // Stringify numbers to store a map of string keys and values which can be sent + // as-is in a fetch call. + if (typeof v === 'number') { + return [k, v.toString()]; + } + return [k, v]; + }) + ); + + // Throw an error if the number of custom signals to be stored exceeds the limit + if ( + Object.keys(updatedSignals).length > RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS + ) { + throw ERROR_FACTORY.create(ErrorCode.CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS, { + maxSignals: RC_CUSTOM_SIGNAL_MAX_ALLOWED_SIGNALS + }); + } + return updatedSignals; +} diff --git a/packages/remote-config/src/storage/storage_cache.ts b/packages/remote-config/src/storage/storage_cache.ts index fc419b0068e..21add07ccd3 100644 --- a/packages/remote-config/src/storage/storage_cache.ts +++ b/packages/remote-config/src/storage/storage_cache.ts @@ -16,7 +16,7 @@ */ import { FetchStatus, CustomSignals } from '@firebase/remote-config-types'; -import { FirebaseRemoteConfigObject } from '../client/remote_config_fetch_client'; +import { FirebaseRemoteConfigObject } from '../public_types'; import { Storage } from './storage'; /** diff --git a/packages/remote-config/test/api.test.ts b/packages/remote-config/test/api.test.ts new file mode 100644 index 00000000000..b1fe658ebae --- /dev/null +++ b/packages/remote-config/test/api.test.ts @@ -0,0 +1,152 @@ +/** + * @license + * Copyright 2025 Google LLC + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { expect } from 'chai'; +import { + ensureInitialized, + fetchAndActivate, + FetchResponse, + getRemoteConfig, + getString +} from '../src'; +import '../test/setup'; +import { + deleteApp, + FirebaseApp, + initializeApp, + _addOrOverwriteComponent +} from '@firebase/app'; +import * as sinon from 'sinon'; +import { Component, ComponentType } from '@firebase/component'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { openDatabase, APP_NAMESPACE_STORE } from '../src/storage/storage'; + +const fakeFirebaseConfig = { + apiKey: 'api-key', + authDomain: 'project-id.firebaseapp.com', + databaseURL: 'https://project-id.firebaseio.com', + projectId: 'project-id', + storageBucket: 'project-id.appspot.com', + messagingSenderId: 'sender-id', + appId: '1:111:web:a1234' +}; + +async function clearDatabase(): Promise { + const db = await openDatabase(); + db.transaction([APP_NAMESPACE_STORE], 'readwrite') + .objectStore(APP_NAMESPACE_STORE) + .clear(); +} + +describe('Remote Config API', () => { + let app: FirebaseApp; + const STUB_FETCH_RESPONSE: FetchResponse = { + status: 200, + eTag: 'asdf', + config: { 'foobar': 'hello world' } + }; + let fetchStub: sinon.SinonStub; + + beforeEach(() => { + fetchStub = sinon.stub(window, 'fetch'); + app = initializeApp(fakeFirebaseConfig); + _addOrOverwriteComponent( + app, + new Component( + 'installations-internal', + () => { + return { + getId: () => Promise.resolve('fis-id'), + getToken: () => Promise.resolve('fis-token') + } as any as FirebaseInstallations; + }, + ComponentType.PUBLIC + ) as any + ); + }); + + afterEach(async () => { + fetchStub.restore(); + await clearDatabase(); + await deleteApp(app); + }); + + function setFetchResponse(response: FetchResponse = { status: 200 }): void { + fetchStub.returns( + Promise.resolve({ + ok: response.status === 200, + status: response.status, + headers: new Headers({ ETag: response.eTag || '' }), + json: () => + Promise.resolve({ + entries: response.config, + state: 'OK' + }) + } as Response) + ); + } + + it('allows multiple initializations if options are same', () => { + const rc = getRemoteConfig(app, { templateId: 'altTemplate' }); + const rc2 = getRemoteConfig(app, { templateId: 'altTemplate' }); + expect(rc).to.equal(rc2); + }); + + it('throws an error if options are different', () => { + getRemoteConfig(app); + expect(() => { + getRemoteConfig(app, { templateId: 'altTemplate' }); + }).to.throw(/Remote Config already initialized/); + }); + + it('makes a fetch call', async () => { + const rc = getRemoteConfig(app); + setFetchResponse(STUB_FETCH_RESPONSE); + await fetchAndActivate(rc); + await ensureInitialized(rc); + expect(getString(rc, 'foobar')).to.equal('hello world'); + }); + + it('calls fetch with default templateId', async () => { + const rc = getRemoteConfig(app); + setFetchResponse(); + await fetchAndActivate(rc); + await ensureInitialized(rc); + expect(fetchStub).to.be.calledOnceWith( + 'https://firebaseremoteconfig.googleapis.com/v1/projects/project-id/namespaces/firebase:fetch?key=api-key', + sinon.match.object + ); + }); + + it('calls fetch with alternate templateId', async () => { + const rc = getRemoteConfig(app, { templateId: 'altTemplate' }); + setFetchResponse(); + await fetchAndActivate(rc); + expect(fetchStub).to.be.calledOnceWith( + 'https://firebaseremoteconfig.googleapis.com/v1/projects/project-id/namespaces/altTemplate:fetch?key=api-key', + sinon.match.object + ); + }); + + it('hydrates with initialFetchResponse', async () => { + const rc = getRemoteConfig(app, { + initialFetchResponse: STUB_FETCH_RESPONSE + }); + await ensureInitialized(rc); + expect(getString(rc, 'foobar')).to.equal('hello world'); + }); +}); diff --git a/packages/remote-config/test/client/caching_client.test.ts b/packages/remote-config/test/client/caching_client.test.ts index a808dffb605..7f8fa04ca9d 100644 --- a/packages/remote-config/test/client/caching_client.test.ts +++ b/packages/remote-config/test/client/caching_client.test.ts @@ -17,9 +17,9 @@ import '../setup'; import { expect } from 'chai'; +import { FetchResponse } from '../../src'; import { RemoteConfigFetchClient, - FetchResponse, FetchRequest, RemoteConfigAbortSignal } from '../../src/client/remote_config_fetch_client'; diff --git a/packages/remote-config/test/client/retrying_client.test.ts b/packages/remote-config/test/client/retrying_client.test.ts index 65641b438bd..9b06ea5a957 100644 --- a/packages/remote-config/test/client/retrying_client.test.ts +++ b/packages/remote-config/test/client/retrying_client.test.ts @@ -18,10 +18,10 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { Storage, ThrottleMetadata } from '../../src/storage/storage'; +import { FetchResponse } from '../../src'; import { RemoteConfigFetchClient, FetchRequest, - FetchResponse, RemoteConfigAbortSignal } from '../../src/client/remote_config_fetch_client'; import { diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 51304bc3b2f..8010f54f26d 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -17,6 +17,7 @@ import { FirebaseApp } from '@firebase/app'; import { + FetchResponse, RemoteConfig as RemoteConfigType, LogLevel as RemoteConfigLogLevel } from '../src/public_types'; @@ -25,10 +26,7 @@ import * as sinon from 'sinon'; import { StorageCache } from '../src/storage/storage_cache'; import { Storage } from '../src/storage/storage'; import { RemoteConfig } from '../src/remote_config'; -import { - RemoteConfigFetchClient, - FetchResponse -} from '../src/client/remote_config_fetch_client'; +import { RemoteConfigFetchClient } from '../src/client/remote_config_fetch_client'; import { Value } from '../src/value'; import './setup'; import { ERROR_FACTORY, ErrorCode } from '../src/errors'; diff --git a/packages/remote-config/test/storage/storage.test.ts b/packages/remote-config/test/storage/storage.test.ts index 7a865107791..5b8418a1187 100644 --- a/packages/remote-config/test/storage/storage.test.ts +++ b/packages/remote-config/test/storage/storage.test.ts @@ -18,12 +18,14 @@ import '../setup'; import { expect } from 'chai'; import { - Storage, ThrottleMetadata, openDatabase, - APP_NAMESPACE_STORE + APP_NAMESPACE_STORE, + IndexedDbStorage, + InMemoryStorage, + Storage } from '../../src/storage/storage'; -import { FetchResponse } from '../../src/client/remote_config_fetch_client'; +import { FetchResponse } from '../../src'; // Clears global IndexedDB state. async function clearDatabase(): Promise { @@ -34,141 +36,161 @@ async function clearDatabase(): Promise { } describe('Storage', () => { - const storage = new Storage('appId', 'appName', 'namespace'); + const indexedDbTestCase = { + getStorage: () => new IndexedDbStorage('appId', 'appName', 'namespace'), + name: 'IndexedDbStorage' + }; + + const inMemoryStorage = { + getStorage: () => new InMemoryStorage(), + name: 'InMemoryStorage' + }; beforeEach(async () => { await clearDatabase(); }); - it('constructs a composite key', async () => { + it(`${indexedDbTestCase.name} constructs a composite key`, async () => { // This is defensive, but the cost of accidentally changing the key composition is high. - expect(storage.createCompositeKey('throttle_metadata')).to.eq( - 'appId,appName,namespace,throttle_metadata' - ); + expect( + indexedDbTestCase.getStorage().createCompositeKey('throttle_metadata') + ).to.eq('appId,appName,namespace,throttle_metadata'); }); - it('sets and gets last fetch attempt status', async () => { - const expectedStatus = 'success'; + for (const { name, getStorage } of [indexedDbTestCase, inMemoryStorage]) { + describe(name, () => { + let storage: Storage; - await storage.setLastFetchStatus(expectedStatus); + beforeEach(() => { + storage = getStorage(); + }); - const actualStatus = await storage.getLastFetchStatus(); + it('sets and gets last fetch attempt status', async () => { + const expectedStatus = 'success'; - expect(actualStatus).to.deep.eq(expectedStatus); - }); + await storage.setLastFetchStatus(expectedStatus); - it('sets and gets last fetch success timestamp', async () => { - const lastSuccessfulFetchTimestampMillis = 123; + const actualStatus = await storage.getLastFetchStatus(); - await storage.setLastSuccessfulFetchTimestampMillis( - lastSuccessfulFetchTimestampMillis - ); + expect(actualStatus).to.deep.eq(expectedStatus); + }); - const actualMetadata = - await storage.getLastSuccessfulFetchTimestampMillis(); + it('sets and gets last fetch success timestamp', async () => { + const lastSuccessfulFetchTimestampMillis = 123; - expect(actualMetadata).to.deep.eq(lastSuccessfulFetchTimestampMillis); - }); + await storage.setLastSuccessfulFetchTimestampMillis( + lastSuccessfulFetchTimestampMillis + ); - it('sets and gets last successful fetch response', async () => { - const lastSuccessfulFetchResponse = { status: 200 } as FetchResponse; + const actualMetadata = + await storage.getLastSuccessfulFetchTimestampMillis(); - await storage.setLastSuccessfulFetchResponse(lastSuccessfulFetchResponse); + expect(actualMetadata).to.deep.eq(lastSuccessfulFetchTimestampMillis); + }); - const actualConfig = await storage.getLastSuccessfulFetchResponse(); + it('sets and gets last successful fetch response', async () => { + const lastSuccessfulFetchResponse = { status: 200 } as FetchResponse; - expect(actualConfig).to.deep.eq(lastSuccessfulFetchResponse); - }); + await storage.setLastSuccessfulFetchResponse( + lastSuccessfulFetchResponse + ); - it('sets and gets active config', async () => { - const expectedConfig = { key: 'value' }; + const actualConfig = await storage.getLastSuccessfulFetchResponse(); - await storage.setActiveConfig(expectedConfig); + expect(actualConfig).to.deep.eq(lastSuccessfulFetchResponse); + }); - const storedConfig = await storage.getActiveConfig(); + it('sets and gets active config', async () => { + const expectedConfig = { key: 'value' }; - expect(storedConfig).to.deep.eq(expectedConfig); - }); + await storage.setActiveConfig(expectedConfig); - it('sets and gets active config etag', async () => { - const expectedEtag = 'etag'; + const storedConfig = await storage.getActiveConfig(); - await storage.setActiveConfigEtag(expectedEtag); + expect(storedConfig).to.deep.eq(expectedConfig); + }); - const storedConfigEtag = await storage.getActiveConfigEtag(); + it('sets and gets active config etag', async () => { + const expectedEtag = 'etag'; - expect(storedConfigEtag).to.deep.eq(expectedEtag); - }); + await storage.setActiveConfigEtag(expectedEtag); - it('sets, gets and deletes throttle metadata', async () => { - const expectedMetadata = { - throttleEndTimeMillis: 1 - } as ThrottleMetadata; + const storedConfigEtag = await storage.getActiveConfigEtag(); - await storage.setThrottleMetadata(expectedMetadata); + expect(storedConfigEtag).to.deep.eq(expectedEtag); + }); - let actualMetadata = await storage.getThrottleMetadata(); + it('sets, gets and deletes throttle metadata', async () => { + const expectedMetadata = { + throttleEndTimeMillis: 1 + } as ThrottleMetadata; - expect(actualMetadata).to.deep.eq(expectedMetadata); + await storage.setThrottleMetadata(expectedMetadata); - await storage.deleteThrottleMetadata(); + let actualMetadata = await storage.getThrottleMetadata(); - actualMetadata = await storage.getThrottleMetadata(); + expect(actualMetadata).to.deep.eq(expectedMetadata); - expect(actualMetadata).to.be.undefined; - }); + await storage.deleteThrottleMetadata(); - it('sets and gets custom signals', async () => { - const customSignals = { key: 'value', key1: 'value1', key2: 1 }; - const customSignalsInStorage = { - key: 'value', - key1: 'value1', - key2: '1' - }; + actualMetadata = await storage.getThrottleMetadata(); - await storage.setCustomSignals(customSignals); + expect(actualMetadata).to.be.undefined; + }); - const storedCustomSignals = await storage.getCustomSignals(); + it('sets and gets custom signals', async () => { + const customSignals = { key: 'value', key1: 'value1', key2: 1 }; + const customSignalsInStorage = { + key: 'value', + key1: 'value1', + key2: '1' + }; - expect(storedCustomSignals).to.deep.eq(customSignalsInStorage); - }); + await storage.setCustomSignals(customSignals); - it('upserts custom signals when key is present in storage', async () => { - const customSignals = { key: 'value', key1: 'value1' }; - const updatedSignals = { key: 'value', key1: 'value2' }; + const storedCustomSignals = await storage.getCustomSignals(); - await storage.setCustomSignals(customSignals); + expect(storedCustomSignals).to.deep.eq(customSignalsInStorage); + }); - await storage.setCustomSignals({ key1: 'value2' }); + it('upserts custom signals when key is present in storage', async () => { + const customSignals = { key: 'value', key1: 'value1' }; + const updatedSignals = { key: 'value', key1: 'value2' }; - const storedCustomSignals = await storage.getCustomSignals(); + await storage.setCustomSignals(customSignals); - expect(storedCustomSignals).to.deep.eq(updatedSignals); - }); + await storage.setCustomSignals({ key1: 'value2' }); - it('deletes custom signal when value supplied is null', async () => { - const customSignals = { key: 'value', key1: 'value1' }; - const updatedSignals = { key: 'value' }; + const storedCustomSignals = await storage.getCustomSignals(); - await storage.setCustomSignals(customSignals); + expect(storedCustomSignals).to.deep.eq(updatedSignals); + }); - await storage.setCustomSignals({ key1: null }); + it('deletes custom signal when value supplied is null', async () => { + const customSignals = { key: 'value', key1: 'value1' }; + const updatedSignals = { key: 'value' }; - const storedCustomSignals = await storage.getCustomSignals(); + await storage.setCustomSignals(customSignals); - expect(storedCustomSignals).to.deep.eq(updatedSignals); - }); + await storage.setCustomSignals({ key1: null }); - it('throws an error when supplied with excess custom signals', async () => { - const customSignals: { [key: string]: string } = {}; - for (let i = 0; i < 101; i++) { - customSignals[`key${i}`] = `value${i}`; - } - - await expect( - storage.setCustomSignals(customSignals) - ).to.eventually.be.rejectedWith( - 'Remote Config: Setting more than 100 custom signals is not supported.' - ); - }); + const storedCustomSignals = await storage.getCustomSignals(); + + expect(storedCustomSignals).to.deep.eq(updatedSignals); + }); + + it('throws an error when supplied with excess custom signals', async () => { + const customSignals: { [key: string]: string } = {}; + for (let i = 0; i < 101; i++) { + customSignals[`key${i}`] = `value${i}`; + } + + await expect( + storage.setCustomSignals(customSignals) + ).to.eventually.be.rejectedWith( + 'Remote Config: Setting more than 100 custom signals is not supported.' + ); + }); + }); + } });