mirror of
https://github.com/hanzoai/firebase-js-sdk.git
synced 2026-09-24 16:59:10 +00:00
Add Remote Config SDK (#2193)
This commit is contained in:
@@ -76,7 +76,7 @@ function getSauceLabsBrowsers(packageName) {
|
||||
*/
|
||||
function getPackageLabels() {
|
||||
const match = testConfigFile.match(
|
||||
/([a-zA-Z]+)\/([a-zA-Z]+)\/karma\.conf\.js/
|
||||
/([a-zA-Z]+)\/([a-zA-Z-]+)\/karma\.conf\.js/
|
||||
);
|
||||
return {
|
||||
type: match[1],
|
||||
|
||||
Vendored
+199
-1
@@ -1099,6 +1099,23 @@ declare namespace firebase {
|
||||
app?: firebase.app.App
|
||||
): firebase.performance.Performance;
|
||||
|
||||
/**
|
||||
* Gets the {@link firebase.remoteConfig.RemoteConfig `RemoteConfig`} instance.
|
||||
*
|
||||
* @webonly
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* // Get the RemoteConfig instance for the default app
|
||||
* const defaultRemoteConfig = firebase.remoteConfig();
|
||||
* ```
|
||||
*
|
||||
* @param app The app to create a Remote Config service for. If not passed, uses the default app.
|
||||
*/
|
||||
function remoteConfig(
|
||||
app?: firebase.app.App
|
||||
): firebase.remoteConfig.RemoteConfig;
|
||||
|
||||
/**
|
||||
* Gets the {@link firebase.analytics.Analytics `Analytics`} service.
|
||||
*
|
||||
@@ -1116,7 +1133,6 @@ declare namespace firebase {
|
||||
* @param app The app to create an analytics service for.
|
||||
* If not passed, uses the default app.
|
||||
*/
|
||||
|
||||
function analytics(app?: firebase.app.App): firebase.analytics.Analytics;
|
||||
}
|
||||
|
||||
@@ -1270,6 +1286,19 @@ declare namespace firebase.app {
|
||||
* ```
|
||||
*/
|
||||
performance(): firebase.performance.Performance;
|
||||
/**
|
||||
* Gets the {@link firebase.remoteConfig.RemoteConfig `RemoteConfig`} instance.
|
||||
*
|
||||
* @webonly
|
||||
*
|
||||
* @example
|
||||
* ```javascript
|
||||
* const rc = app.remoteConfig();
|
||||
* // The above is shorthand for:
|
||||
* // const rc = firebase.remoteConfig(app);
|
||||
* ```
|
||||
*/
|
||||
remoteConfig(): firebase.remoteConfig.RemoteConfig;
|
||||
/**
|
||||
* Gets the {@link firebase.analytics.Analytics `Analytics`} service for the
|
||||
* current app. If the current app is not the default one, throws an error.
|
||||
@@ -1433,6 +1462,175 @@ declare namespace firebase.performance {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @webonly
|
||||
*/
|
||||
declare namespace firebase.remoteConfig {
|
||||
/**
|
||||
* The Firebase Remote Config service interface.
|
||||
*
|
||||
* Do not call this constructor directly. Instead, use
|
||||
* {@link firebase.remoteConfig `firebase.remoteConfig()`}.
|
||||
*/
|
||||
export interface RemoteConfig {
|
||||
/**
|
||||
* Defines configuration for the Remote Config SDK.
|
||||
*/
|
||||
settings: Settings;
|
||||
|
||||
/**
|
||||
* Object containing default values for conigs.
|
||||
*/
|
||||
defaultConfig: { [key: string]: string | number | boolean };
|
||||
|
||||
/**
|
||||
* The Unix timestamp in milliseconds of the last <i>successful</i> fetch, or negative one if
|
||||
* the {@link RemoteConfig} instance either hasn't fetched or initialization
|
||||
* is incomplete.
|
||||
*/
|
||||
fetchTimeMillis: number;
|
||||
|
||||
/**
|
||||
* The status of the last fetch <i>attempt</i>.
|
||||
*/
|
||||
lastFetchStatus: FetchStatus;
|
||||
|
||||
/**
|
||||
* Makes the last fetched config available to the getters.
|
||||
* Returns a promise which resolves to true if the current call activated the fetched configs.
|
||||
* If the fetched configs were already activated, the promise will resolve to false.
|
||||
*/
|
||||
activate(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Ensures the last activated config are available to the getters.
|
||||
*/
|
||||
ensureInitialized(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetches and caches configuration from the Remote Config service.
|
||||
*/
|
||||
fetch(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Performs fetch and activate operations, as a convenience.
|
||||
* Returns a promise which resolves to true if the current call activated the fetched configs.
|
||||
* If the fetched configs were already activated, the promise will resolve to false.
|
||||
*/
|
||||
fetchAndActivate(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets all config.
|
||||
*/
|
||||
getAll(): { [key: string]: Value };
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a boolean.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asBoolean()</code>.
|
||||
*/
|
||||
getBoolean(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a number.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asNumber()</code>.
|
||||
*/
|
||||
getNumber(key: string): number;
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a String.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asString()</code>.
|
||||
*/
|
||||
getString(key: string): string;
|
||||
|
||||
/**
|
||||
* Gets the {@link Value} for the given key.
|
||||
*/
|
||||
getValue(key: string): Value;
|
||||
|
||||
/**
|
||||
* Defines the log level to use.
|
||||
*/
|
||||
setLogLevel(logLevel: LogLevel): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates the source of a value.
|
||||
*
|
||||
* <ul>
|
||||
* <li>"static" indicates the value was defined by a static constant.</li>
|
||||
* <li>"default" indicates the value was defined by default config.</li>
|
||||
* <li>"remote" indicates the value was defined by fetched config.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export type ValueSource = 'static' | 'default' | 'remote';
|
||||
|
||||
/**
|
||||
* Wraps a value with metadata and type-safe getters.
|
||||
*/
|
||||
export interface Value {
|
||||
/**
|
||||
* Gets the value as a boolean.
|
||||
*
|
||||
* The following values (case insensitive) are interpreted as true:
|
||||
* "1", "true", "t", "yes", "y", "on". Other values are interpreted as false.
|
||||
*/
|
||||
asBoolean(): boolean;
|
||||
|
||||
/**
|
||||
* Gets the value as a number. Comparable to calling <code>Number(value) || 0</code>.
|
||||
*/
|
||||
asNumber(): number;
|
||||
|
||||
/**
|
||||
* Gets the value as a string.
|
||||
*/
|
||||
asString(): string;
|
||||
|
||||
/**
|
||||
* Gets the {@link ValueSource} for the given key.
|
||||
*/
|
||||
getSource(): ValueSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines configuration options for the Remote Config SDK.
|
||||
*/
|
||||
export interface Settings {
|
||||
/**
|
||||
* Defines the maximum age in milliseconds of an entry in the config cache before
|
||||
* it is considered stale. Defaults to 43200000 (Twelve hours).
|
||||
*/
|
||||
minimumFetchIntervalMillis: number;
|
||||
|
||||
/**
|
||||
* Defines the maximum amount of milliseconds to wait for a response when fetching
|
||||
* configuration from the Remote Config server. Defaults to 60000 (One minute).
|
||||
*/
|
||||
fetchTimeoutMillis: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the outcome of the last attempt to fetch config from the Firebase Remote Config server.
|
||||
*
|
||||
* <ul>
|
||||
* <li>"no-fetch-yet" indicates the {@link RemoteConfig} instance has not yet attempted
|
||||
* to fetch config, or that SDK initialization is incomplete.</li>
|
||||
* <li>"success" indicates the last attempt succeeded.</li>
|
||||
* <li>"failure" indicates the last attempt failed.</li>
|
||||
* <li>"throttle" indicates the last attempt was rate-limited.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle';
|
||||
|
||||
/**
|
||||
* Defines levels of Remote Config logging.
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'error' | 'silent';
|
||||
}
|
||||
|
||||
/**
|
||||
* @webonly
|
||||
*/
|
||||
|
||||
@@ -12,7 +12,8 @@
|
||||
"firebase",
|
||||
"realtime",
|
||||
"storage",
|
||||
"performance"
|
||||
"performance",
|
||||
"remote-config"
|
||||
],
|
||||
"files": [
|
||||
"**/dist/",
|
||||
@@ -48,6 +49,7 @@
|
||||
"@firebase/polyfill": "0.3.22",
|
||||
"@firebase/storage": "0.3.12",
|
||||
"@firebase/performance": "0.2.19",
|
||||
"@firebase/remote-config": "0.1.0",
|
||||
"@firebase/util": "0.2.28"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -73,6 +75,7 @@
|
||||
"installations",
|
||||
"messaging",
|
||||
"storage",
|
||||
"performance"
|
||||
"performance",
|
||||
"remote-config"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google Inc.
|
||||
*
|
||||
* 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 '@firebase/remote-config';
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "firebase/remote-config",
|
||||
"main": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"typings": "../empty-import.d.ts"
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
# @firebase/remoteconfig-types
|
||||
|
||||
**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.**
|
||||
+174
@@ -0,0 +1,174 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
export interface RemoteConfig {
|
||||
/**
|
||||
* Defines configuration for the Remote Config SDK.
|
||||
*/
|
||||
settings: Settings;
|
||||
|
||||
/**
|
||||
* Object containing default values for conigs.
|
||||
*/
|
||||
defaultConfig: { [key: string]: string | number | boolean };
|
||||
|
||||
/**
|
||||
* The Unix timestamp in milliseconds of the last <i>successful</i> fetch, or negative one if
|
||||
* the {@link RemoteConfig} instance either hasn't fetched or initialization
|
||||
* is incomplete.
|
||||
*/
|
||||
fetchTimeMillis: number;
|
||||
|
||||
/**
|
||||
* The status of the last fetch <i>attempt</i>.
|
||||
*/
|
||||
lastFetchStatus: FetchStatus;
|
||||
|
||||
/**
|
||||
* Makes the last fetched config available to the getters.
|
||||
* Returns a promise which resolves to true if the current call activated the fetched configs.
|
||||
* If the fetched configs were already activated, the promise will resolve to false.
|
||||
*/
|
||||
activate(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Ensures the last activated config are available to the getters.
|
||||
*/
|
||||
ensureInitialized(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Fetches and caches configuration from the Remote Config service.
|
||||
*/
|
||||
fetch(): Promise<void>;
|
||||
|
||||
/**
|
||||
* Performs fetch and activate operations, as a convenience.
|
||||
* Returns a promise which resolves to true if the current call activated the fetched configs.
|
||||
* If the fetched configs were already activated, the promise will resolve to false.
|
||||
*/
|
||||
fetchAndActivate(): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* Gets all config.
|
||||
*/
|
||||
getAll(): { [key: string]: Value };
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a boolean.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asBoolean()</code>.
|
||||
*/
|
||||
getBoolean(key: string): boolean;
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a number.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asNumber()</code>.
|
||||
*/
|
||||
getNumber(key: string): number;
|
||||
|
||||
/**
|
||||
* Gets the value for the given key as a String.
|
||||
*
|
||||
* Convenience method for calling <code>remoteConfig.getValue(key).asString()</code>.
|
||||
*/
|
||||
getString(key: string): string;
|
||||
|
||||
/**
|
||||
* Gets the {@link Value} for the given key.
|
||||
*/
|
||||
getValue(key: string): Value;
|
||||
|
||||
/**
|
||||
* Defines the log level to use.
|
||||
*/
|
||||
setLogLevel(logLevel: LogLevel): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Indicates the source of a value.
|
||||
*
|
||||
* <ul>
|
||||
* <li>"static" indicates the value was defined by a static constant.</li>
|
||||
* <li>"default" indicates the value was defined by default config.</li>
|
||||
* <li>"remote" indicates the value was defined by fetched config.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export type ValueSource = 'static' | 'default' | 'remote';
|
||||
|
||||
/**
|
||||
* Wraps a value with metadata and type-safe getters.
|
||||
*/
|
||||
export interface Value {
|
||||
/**
|
||||
* Gets the value as a boolean.
|
||||
*
|
||||
* The following values (case insensitive) are interpreted as true:
|
||||
* "1", "true", "t", "yes", "y", "on". Other values are interpreted as false.
|
||||
*/
|
||||
asBoolean(): boolean;
|
||||
|
||||
/**
|
||||
* Gets the value as a number. Comparable to calling <code>Number(value) || 0</code>.
|
||||
*/
|
||||
asNumber(): number;
|
||||
|
||||
/**
|
||||
* Gets the value as a string.
|
||||
*/
|
||||
asString(): string;
|
||||
|
||||
/**
|
||||
* Gets the {@link ValueSource} for the given key.
|
||||
*/
|
||||
getSource(): ValueSource;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines configuration options for the Remote Config SDK.
|
||||
*/
|
||||
export interface Settings {
|
||||
/**
|
||||
* Defines the maximum age in milliseconds of an entry in the config cache before
|
||||
* it is considered stale. Defaults to 43200000 (Twelve hours).
|
||||
*/
|
||||
minimumFetchIntervalMillis: number;
|
||||
|
||||
/**
|
||||
* Defines the maximum amount of milliseconds to wait for a response when fetching
|
||||
* configuration from the Remote Config server. Defaults to 60000 (One minute).
|
||||
*/
|
||||
fetchTimeoutMillis: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Summarizes the outcome of the last attempt to fetch config from the Firebase Remote Config server.
|
||||
*
|
||||
* <ul>
|
||||
* <li>"no-fetch-yet" indicates the {@link RemoteConfig} instance has not yet attempted
|
||||
* to fetch config, or that SDK initialization is incomplete.</li>
|
||||
* <li>"success" indicates the last attempt succeeded.</li>
|
||||
* <li>"failure" indicates the last attempt failed.</li>
|
||||
* <li>"throttle" indicates the last attempt was rate-limited.</li>
|
||||
* </ul>
|
||||
*/
|
||||
export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle';
|
||||
|
||||
/**
|
||||
* Defines levels of Remote Config logging.
|
||||
*/
|
||||
export type LogLevel = 'debug' | 'error' | 'silent';
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@firebase/remote-config-types",
|
||||
"version": "0.1.0",
|
||||
"description": "@firebase/remote-config Types",
|
||||
"author": "Firebase <[email protected]> (https://firebase.google.com/)",
|
||||
"license": "Apache-2.0",
|
||||
"scripts": {
|
||||
"test": "tsc"
|
||||
},
|
||||
"files": [
|
||||
"index.d.ts"
|
||||
],
|
||||
"repository": {
|
||||
"directory": "packages/remote-config-types",
|
||||
"type": "git",
|
||||
"url": "https://github.com/firebase/firebase-js-sdk.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/firebase/firebase-js-sdk/issues"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "3.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"noEmit": true
|
||||
},
|
||||
"exclude": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"extends": "../../config/.eslintrc.json",
|
||||
"parserOptions": {
|
||||
"project": "tsconfig.json"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
# This file is left intentionally blank
|
||||
@@ -0,0 +1,27 @@
|
||||
# @firebase/remoteconfig
|
||||
|
||||
This is the [Remote Config](https://firebase.google.com/docs/remote-config/) component of the
|
||||
[Firebase JS SDK](https://www.npmjs.com/package/firebase).
|
||||
|
||||
**This package is not intended for direct usage, and should only be used via the officially
|
||||
supported [firebase](https://www.npmjs.com/package/firebase) package.**
|
||||
|
||||
## Contributing
|
||||
|
||||
Setup:
|
||||
|
||||
1. Run `yarn` in repo root
|
||||
|
||||
Format:
|
||||
|
||||
1. Run `yarn prettier` in RC package
|
||||
|
||||
Unit test:
|
||||
|
||||
1. Run `yarn test` in RC package
|
||||
|
||||
End-to-end test:
|
||||
|
||||
1. Run `yarn build` in RC package
|
||||
1. Run `yarn build` in Firebase package
|
||||
1. Open test_app/index.html in a browser
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 firebase from '@firebase/app';
|
||||
import { _FirebaseNamespace } from '@firebase/app-types/private';
|
||||
import '@firebase/installations';
|
||||
import { RemoteConfig as RemoteConfigType } from '@firebase/remote-config-types';
|
||||
import { CachingClient } from './src/client/caching_client';
|
||||
import { RestClient } from './src/client/rest_client';
|
||||
import { RemoteConfig } from './src/remote_config';
|
||||
import { Storage } from './src/storage/storage';
|
||||
import { StorageCache } from './src/storage/storage_cache';
|
||||
import { ERROR_FACTORY, ErrorCode } from './src/errors';
|
||||
import { RetryingClient } from './src/client/retrying_client';
|
||||
import { Logger, LogLevel as FirebaseLogLevel } from '@firebase/logger';
|
||||
import { name as packageName } from './package.json';
|
||||
|
||||
// Facilitates debugging by enabling settings changes without rebuilding asset.
|
||||
// Note these debug options are not part of a documented, supported API and can change at any time.
|
||||
// Consolidates debug options for easier discovery.
|
||||
// Uses transient variables on window to avoid lingering state causing panic.
|
||||
declare global {
|
||||
interface Window {
|
||||
FIREBASE_REMOTE_CONFIG_URL_BASE: string;
|
||||
}
|
||||
}
|
||||
|
||||
export function registerRemoteConfig(
|
||||
firebaseInstance: _FirebaseNamespace
|
||||
): void {
|
||||
firebaseInstance.INTERNAL.registerService(
|
||||
'remoteConfig',
|
||||
(app, _, namespace) => {
|
||||
// Guards against the SDK being used in non-browser environments.
|
||||
if (typeof window === 'undefined') {
|
||||
throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_WINDOW);
|
||||
}
|
||||
|
||||
// Normalizes optional inputs.
|
||||
const { projectId, apiKey, appId } = app.options;
|
||||
if (!projectId) {
|
||||
throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_PROJECT_ID);
|
||||
}
|
||||
if (!apiKey) {
|
||||
throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_API_KEY);
|
||||
}
|
||||
if (!appId) {
|
||||
throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_APP_ID);
|
||||
}
|
||||
namespace = namespace || 'firebase';
|
||||
|
||||
const storage = new Storage(appId, app.name, namespace);
|
||||
const storageCache = new StorageCache(storage);
|
||||
|
||||
const logger = new Logger(packageName);
|
||||
|
||||
// Sets ERROR as the default log level.
|
||||
// See RemoteConfig#setLogLevel for corresponding normalization to ERROR log level.
|
||||
logger.logLevel = FirebaseLogLevel.ERROR;
|
||||
|
||||
const restClient = new RestClient(
|
||||
app.installations(),
|
||||
// Uses the JS SDK version, by which the RC package version can be deduced, if necessary.
|
||||
firebaseInstance.SDK_VERSION,
|
||||
namespace,
|
||||
projectId,
|
||||
apiKey,
|
||||
appId
|
||||
);
|
||||
const retryingClient = new RetryingClient(restClient, storage);
|
||||
const cachingClient = new CachingClient(
|
||||
retryingClient,
|
||||
storage,
|
||||
storageCache,
|
||||
logger
|
||||
);
|
||||
|
||||
const remoteConfigInstance = new RemoteConfig(
|
||||
app,
|
||||
cachingClient,
|
||||
storageCache,
|
||||
storage,
|
||||
logger
|
||||
);
|
||||
|
||||
// Starts warming cache.
|
||||
// eslint-disable-next-line @typescript-eslint/no-floating-promises
|
||||
remoteConfigInstance.ensureInitialized();
|
||||
|
||||
return remoteConfigInstance;
|
||||
},
|
||||
undefined,
|
||||
undefined,
|
||||
true /* allowMultipleInstances */
|
||||
);
|
||||
}
|
||||
|
||||
registerRemoteConfig(firebase as _FirebaseNamespace);
|
||||
|
||||
declare module '@firebase/app-types' {
|
||||
interface FirebaseNamespace {
|
||||
remoteConfig?: {
|
||||
(app?: FirebaseApp): RemoteConfigType;
|
||||
};
|
||||
}
|
||||
interface FirebaseApp {
|
||||
remoteConfig(): RemoteConfigType;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const karma = require('karma');
|
||||
const path = require('path');
|
||||
const karmaBase = require('../../config/karma.base');
|
||||
|
||||
const files = [`test/**/*`];
|
||||
|
||||
module.exports = function(config) {
|
||||
const karmaConfig = Object.assign({}, karmaBase, {
|
||||
// files to load into karma
|
||||
files: files,
|
||||
// frameworks to use
|
||||
// available frameworks: https://npmjs.org/browse/keyword/karma-adapter
|
||||
frameworks: ['mocha']
|
||||
});
|
||||
|
||||
config.set(karmaConfig);
|
||||
};
|
||||
|
||||
module.exports.files = files;
|
||||
@@ -0,0 +1,69 @@
|
||||
{
|
||||
"name": "@firebase/remote-config",
|
||||
"version": "0.1.0",
|
||||
"description": "The Remote Config package of the Firebase JS SDK",
|
||||
"author": "Firebase <[email protected]> (https://firebase.google.com/)",
|
||||
"main": "dist/index.node.cjs.js",
|
||||
"browser": "dist/index.cjs.js",
|
||||
"module": "dist/index.esm.js",
|
||||
"esm2017": "dist/index.esm2017.js",
|
||||
"files": [
|
||||
"dist"
|
||||
],
|
||||
"scripts": {
|
||||
"lint": "eslint -c .eslintrc.json '**/*.ts' --ignore-path '../../.gitignore'",
|
||||
"lint:fix": "eslint --fix -c .eslintrc.json '**/*.ts' --ignore-path '../../.gitignore'",
|
||||
"build": "rollup -c",
|
||||
"dev": "rollup -c -w",
|
||||
"test": "run-p lint test:browser",
|
||||
"test:browser": "karma start --single-run",
|
||||
"test:debug": "karma start --browsers=Chrome --auto-watch",
|
||||
"prettier": "prettier --write '{src,test}/**/*.{js,ts}'",
|
||||
"prepare": "yarn build"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@firebase/app": "0.x",
|
||||
"@firebase/app-types": "0.x"
|
||||
},
|
||||
"dependencies": {
|
||||
"@firebase/installations": "0.2.7",
|
||||
"@firebase/logger": "0.1.25",
|
||||
"@firebase/remote-config-types": "0.1.0",
|
||||
"@firebase/util": "0.2.28",
|
||||
"tslib": "1.10.0"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@types/chai": "4.2.0",
|
||||
"@types/mocha": "5.2.7",
|
||||
"@types/sinon": "7.0.13",
|
||||
"@typescript-eslint/eslint-plugin": "2.0.0",
|
||||
"@typescript-eslint/eslint-plugin-tslint": "2.0.0",
|
||||
"@typescript-eslint/parser": "2.0.0",
|
||||
"chai": "4.2.0",
|
||||
"chai-as-promised": "7.1.1",
|
||||
"eslint": "5.16.0",
|
||||
"eslint-plugin-import": "2.18.2",
|
||||
"mocha": "6.2.0",
|
||||
"rollup": "1.20.3",
|
||||
"rollup-plugin-typescript2": "0.24.0",
|
||||
"sinon": "7.4.1",
|
||||
"sinon-chai": "3.3.0",
|
||||
"typescript": "3.5.3"
|
||||
},
|
||||
"repository": {
|
||||
"directory": "packages/remote-config",
|
||||
"type": "git",
|
||||
"url": "https://github.com/firebase/firebase-js-sdk.git"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/firebase/firebase-js-sdk/issues"
|
||||
},
|
||||
"typings": "dist/index.d.ts",
|
||||
"nyc": {
|
||||
"extension": [
|
||||
".ts"
|
||||
],
|
||||
"reportDir": "./coverage/node"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 json from 'rollup-plugin-json'; // Enables package.json import in TypeScript.
|
||||
import typescriptPlugin from 'rollup-plugin-typescript2';
|
||||
import typescript from 'typescript';
|
||||
import pkg from './package.json';
|
||||
|
||||
const deps = Object.keys(
|
||||
Object.assign({}, pkg.peerDependencies, pkg.dependencies)
|
||||
);
|
||||
|
||||
/**
|
||||
* ES5 Builds
|
||||
*/
|
||||
const es5BuildPlugins = [
|
||||
typescriptPlugin({
|
||||
typescript
|
||||
}),
|
||||
json()
|
||||
];
|
||||
|
||||
const es5Builds = [
|
||||
/**
|
||||
* Browser Builds
|
||||
*/
|
||||
{
|
||||
input: 'index.ts',
|
||||
output: [
|
||||
{ file: pkg.browser, format: 'cjs', sourcemap: true },
|
||||
{ file: pkg.module, format: 'es', sourcemap: true }
|
||||
],
|
||||
plugins: es5BuildPlugins,
|
||||
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
|
||||
}
|
||||
];
|
||||
|
||||
/**
|
||||
* ES2017 Builds
|
||||
*/
|
||||
const es2017BuildPlugins = [
|
||||
typescriptPlugin({
|
||||
typescript,
|
||||
tsconfigOverride: {
|
||||
compilerOptions: {
|
||||
target: 'es2017'
|
||||
}
|
||||
}
|
||||
}),
|
||||
json({ preferConst: true })
|
||||
];
|
||||
|
||||
const es2017Builds = [
|
||||
/**
|
||||
* Browser Builds
|
||||
*/
|
||||
{
|
||||
input: 'index.ts',
|
||||
output: {
|
||||
file: pkg.esm2017,
|
||||
format: 'es',
|
||||
sourcemap: true
|
||||
},
|
||||
plugins: es2017BuildPlugins,
|
||||
external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`))
|
||||
}
|
||||
];
|
||||
|
||||
export default [...es5Builds, ...es2017Builds];
|
||||
@@ -0,0 +1,123 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { StorageCache } from '../storage/storage_cache';
|
||||
import {
|
||||
FetchResponse,
|
||||
RemoteConfigFetchClient,
|
||||
FetchRequest
|
||||
} from './remote_config_fetch_client';
|
||||
import { Storage } from '../storage/storage';
|
||||
import { Logger } from '@firebase/logger';
|
||||
|
||||
/**
|
||||
* Implements the {@link RemoteConfigClient} abstraction with success response caching.
|
||||
*
|
||||
* <p>Comparable to the browser's Cache API for responses, but the Cache API requires a Service
|
||||
* Worker, which requires HTTPS, which would significantly complicate SDK installation. Also, the
|
||||
* Cache API doesn't support matching entries by time.
|
||||
*/
|
||||
export class CachingClient implements RemoteConfigFetchClient {
|
||||
constructor(
|
||||
private readonly client: RemoteConfigFetchClient,
|
||||
private readonly storage: Storage,
|
||||
private readonly storageCache: StorageCache,
|
||||
private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Returns true if the age of the cached fetched configs is less than or equal to
|
||||
* {@link Settings#minimumFetchIntervalInSeconds}.
|
||||
*
|
||||
* <p>This is comparable to passing `headers = { 'Cache-Control': max-age <maxAge> }` to the
|
||||
* native Fetch API.
|
||||
*
|
||||
* <p>Visible for testing.
|
||||
*/
|
||||
isCachedDataFresh(
|
||||
cacheMaxAgeMillis: number,
|
||||
lastSuccessfulFetchTimestampMillis: number | undefined
|
||||
): boolean {
|
||||
// Cache can only be fresh if it's populated.
|
||||
if (!lastSuccessfulFetchTimestampMillis) {
|
||||
this.logger.debug('Config fetch cache check. Cache unpopulated.');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Calculates age of cache entry.
|
||||
const cacheAgeMillis = Date.now() - lastSuccessfulFetchTimestampMillis;
|
||||
|
||||
const isCachedDataFresh = cacheAgeMillis <= cacheMaxAgeMillis;
|
||||
|
||||
this.logger.debug(
|
||||
'Config fetch cache check.' +
|
||||
` Cache age millis: ${cacheAgeMillis}.` +
|
||||
` Cache max age millis (minimumFetchIntervalMillis setting): ${cacheMaxAgeMillis}.` +
|
||||
` Is cache hit: ${isCachedDataFresh}.`
|
||||
);
|
||||
|
||||
return isCachedDataFresh;
|
||||
}
|
||||
|
||||
async fetch(request: FetchRequest): Promise<FetchResponse> {
|
||||
// Reads from persisted storage to avoid cache miss if callers don't wait on initialization.
|
||||
const [
|
||||
lastSuccessfulFetchTimestampMillis,
|
||||
lastSuccessfulFetchResponse
|
||||
] = await Promise.all([
|
||||
this.storage.getLastSuccessfulFetchTimestampMillis(),
|
||||
this.storage.getLastSuccessfulFetchResponse()
|
||||
]);
|
||||
|
||||
// Exits early on cache hit.
|
||||
if (
|
||||
lastSuccessfulFetchResponse &&
|
||||
this.isCachedDataFresh(
|
||||
request.cacheMaxAgeMillis,
|
||||
lastSuccessfulFetchTimestampMillis
|
||||
)
|
||||
) {
|
||||
return lastSuccessfulFetchResponse;
|
||||
}
|
||||
|
||||
// Deviates from pure decorator by not honoring a passed ETag since we don't have a public API
|
||||
// that allows the caller to pass an ETag.
|
||||
request.eTag =
|
||||
lastSuccessfulFetchResponse && lastSuccessfulFetchResponse.eTag;
|
||||
|
||||
// Falls back to service on cache miss.
|
||||
const response = await this.client.fetch(request);
|
||||
|
||||
// Fetch throws for non-success responses, so success is guaranteed here.
|
||||
|
||||
const storageOperations = [
|
||||
// Uses write-through cache for consistency with synchronous public API.
|
||||
this.storageCache.setLastSuccessfulFetchTimestampMillis(Date.now())
|
||||
];
|
||||
|
||||
if (response.status === 200) {
|
||||
// Caches response only if it has changed, ie non-304 responses.
|
||||
storageOperations.push(
|
||||
this.storage.setLastSuccessfulFetchResponse(response)
|
||||
);
|
||||
}
|
||||
|
||||
await Promise.all(storageOperations);
|
||||
|
||||
return response;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The amount of milliseconds to exponentially increase.
|
||||
*/
|
||||
const INTERVAL_MILLIS = 1000;
|
||||
|
||||
/**
|
||||
* The factor to backoff by.
|
||||
* Should be a number greater than 1.
|
||||
*/
|
||||
const BACKOFF_FACTOR = 2;
|
||||
|
||||
/**
|
||||
* The maximum milliseconds to increase to.
|
||||
*
|
||||
* <p>Visible for testing
|
||||
*/
|
||||
export const MAX_VALUE_MILLIS = 4 * 60 * 60 * 1000; // Four hours, like iOS and Android.
|
||||
|
||||
/**
|
||||
* The percentage of backoff time to randomize by.
|
||||
* See
|
||||
* http://go/safe-client-behavior#step-1-determine-the-appropriate-retry-interval-to-handle-spike-traffic
|
||||
* for context.
|
||||
*
|
||||
* <p>Visible for testing
|
||||
*/
|
||||
export const RANDOM_FACTOR = 0.5;
|
||||
|
||||
/**
|
||||
* Based on the backoff method from
|
||||
* https://github.com/google/closure-library/blob/master/closure/goog/math/exponentialbackoff.js.
|
||||
* Extracted here so we don't need to pass metadata and a stateful ExponentialBackoff object around.
|
||||
*/
|
||||
export function calculateBackoffMillis(backoffCount: number): number {
|
||||
// Calculates an exponentially increasing value.
|
||||
// Deviation: calculates value from count and a constant interval, so we only need to save value
|
||||
// and count to restore state.
|
||||
const currBaseValue =
|
||||
INTERVAL_MILLIS * Math.pow(BACKOFF_FACTOR, backoffCount);
|
||||
|
||||
// A random "fuzz" to avoid waves of retries.
|
||||
// Deviation: randomFactor is required.
|
||||
const randomWait = Math.round(
|
||||
// A fraction of the backoff value to add/subtract.
|
||||
// Deviation: changes multiplication order to improve readability.
|
||||
RANDOM_FACTOR *
|
||||
currBaseValue *
|
||||
// A random float (rounded to int by Math.round above) in the range [-1, 1]. Determines
|
||||
// if we add or subtract.
|
||||
(Math.random() - 0.5) *
|
||||
2
|
||||
);
|
||||
|
||||
// Limits backoff to max to avoid effectively permanent backoff.
|
||||
return Math.min(MAX_VALUE_MILLIS, currBaseValue + randomWait);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Defines a client, as in https://en.wikipedia.org/wiki/Client%E2%80%93server_model, for the
|
||||
* Remote Config server (https://firebase.google.com/docs/reference/remote-config/rest).
|
||||
*
|
||||
* <p>Abstracts throttle, response cache and network implementation details.
|
||||
*
|
||||
* <p>Modeled after the native {@link GlobalFetch} interface, which is relatively modern and
|
||||
* convenient, but simplified for Remote Config's use case.
|
||||
*
|
||||
* Disambiguation: {@link GlobalFetch} interface and the Remote Config service define "fetch"
|
||||
* methods. The RestClient uses the former to make HTTP calls. This interface abstracts the latter.
|
||||
*/
|
||||
export interface RemoteConfigFetchClient {
|
||||
/**
|
||||
* @throws if response status is not 200 or 304.
|
||||
*/
|
||||
fetch(request: FetchRequest): Promise<FetchResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a self-descriptive reference for config key-value pairs.
|
||||
*/
|
||||
export interface FirebaseRemoteConfigObject {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Shims a minimal AbortSignal.
|
||||
*
|
||||
* <p>AbortController's AbortSignal conveniently decouples fetch timeout logic from other aspects
|
||||
* of networking, such as retries. Firebase doesn't use AbortController enough to justify a
|
||||
* polyfill recommendation, like we do with the Fetch API, but this minimal shim can easily be
|
||||
* swapped out if/when we do.
|
||||
*/
|
||||
export class RemoteConfigAbortSignal {
|
||||
listeners: Array<() => void> = [];
|
||||
addEventListener(listener: () => void): void {
|
||||
this.listeners.push(listener);
|
||||
}
|
||||
abort(): void {
|
||||
this.listeners.forEach(listener => listener());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines per-request inputs for the Remote Config fetch request.
|
||||
*
|
||||
* <p>Modeled after the native {@link Request} interface, but simplified for Remote Config's
|
||||
* use case.
|
||||
*/
|
||||
export interface FetchRequest {
|
||||
/**
|
||||
* Uses cached config if it is younger than this age.
|
||||
*
|
||||
* <p>Required because it's defined by settings, which always have a value.
|
||||
*
|
||||
* <p>Comparable to passing `headers = { 'Cache-Control': max-age <maxAge> }` to the native
|
||||
* Fetch API.
|
||||
*/
|
||||
cacheMaxAgeMillis: number;
|
||||
|
||||
/**
|
||||
* An event bus for the signal to abort a request.
|
||||
*
|
||||
* <p>Required because all requests should be abortable.
|
||||
*
|
||||
* <p>Comparable to the native
|
||||
* Fetch API's "signal" field on its request configuration object
|
||||
* https://fetch.spec.whatwg.org/#dom-requestinit-signal.
|
||||
*
|
||||
* <p>Disambiguation: Remote Config commonly refers to API inputs as
|
||||
* "signals". See the private ConfigFetchRequestBody interface for those:
|
||||
* http://google3/firebase/remote_config/web/src/core/rest_client.ts?l=14&rcl=255515243.
|
||||
*/
|
||||
signal: RemoteConfigAbortSignal;
|
||||
|
||||
/**
|
||||
* The ETag header value from the last response.
|
||||
*
|
||||
* <p>Optional in case this is the first request.
|
||||
*
|
||||
* <p>Comparable to passing `headers = { 'If-None-Match': <eTag> }` to the native Fetch API.
|
||||
*/
|
||||
eTag?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Defines a successful response (200 or 304).
|
||||
*
|
||||
* <p>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.
|
||||
*
|
||||
* <p>{@link RemoteConfigClient} is modeled after the native {@link GlobalFetch} interface, so
|
||||
* HTTP status is first-class.
|
||||
*
|
||||
* <p>Disambiguation: the fetch response returns a legacy "state" value that is redundant with the
|
||||
* HTTP status code. The former is normalized into the latter.
|
||||
*/
|
||||
status: number;
|
||||
|
||||
/**
|
||||
* Defines the ETag response header value.
|
||||
*
|
||||
* <p>Only defined for 200 and 304 responses.
|
||||
*/
|
||||
eTag?: string;
|
||||
|
||||
/**
|
||||
* Defines the map of parameters returned as "entries" in the fetch response body.
|
||||
*
|
||||
* <p>Only defined for 200 responses.
|
||||
*/
|
||||
config?: FirebaseRemoteConfigObject;
|
||||
|
||||
// Note: we're not extracting experiment metadata until
|
||||
// ABT and Analytics have Web SDKs.
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { FirebaseInstallations } from '@firebase/installations-types';
|
||||
import {
|
||||
FetchResponse,
|
||||
RemoteConfigFetchClient,
|
||||
FirebaseRemoteConfigObject,
|
||||
FetchRequest
|
||||
} from './remote_config_fetch_client';
|
||||
import { ERROR_FACTORY, ErrorCode } from '../errors';
|
||||
import { getUserLanguage } from '../language';
|
||||
|
||||
/**
|
||||
* Defines request body parameters required to call the fetch API:
|
||||
* https://firebase.google.com/docs/reference/remote-config/rest
|
||||
*
|
||||
* <p>Not exported because this file encapsulates REST API specifics.
|
||||
*
|
||||
* <p>Not passing User Properties because Analytics' source of truth on Web is server-side.
|
||||
*/
|
||||
interface FetchRequestBody {
|
||||
// Disables camelcase linting for request body params.
|
||||
/* eslint-disable camelcase*/
|
||||
sdk_version: string;
|
||||
app_instance_id: string;
|
||||
app_instance_id_token: string;
|
||||
app_id: string;
|
||||
language_code: string;
|
||||
/* eslint-enable camelcase */
|
||||
}
|
||||
|
||||
/**
|
||||
* Implements the Client abstraction for the Remote Config REST API.
|
||||
*/
|
||||
export class RestClient implements RemoteConfigFetchClient {
|
||||
constructor(
|
||||
private readonly firebaseInstallations: FirebaseInstallations,
|
||||
private readonly sdkVersion: string,
|
||||
private readonly namespace: string,
|
||||
private readonly projectId: string,
|
||||
private readonly apiKey: string,
|
||||
private readonly appId: string
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Fetches from the Remote Config REST API.
|
||||
*
|
||||
* @throws a {@link ErrorCode.FETCH_NETWORK} error if {@link GlobalFetch#fetch} can't
|
||||
* connect to the network.
|
||||
* @throws a {@link ErrorCode.FETCH_PARSE} error if {@link Response#json} can't parse the
|
||||
* fetch response.
|
||||
* @throws a {@link ErrorCode.FETCH_STATUS} error if the service returns an HTTP error status.
|
||||
*/
|
||||
async fetch(request: FetchRequest): Promise<FetchResponse> {
|
||||
const [installationId, installationToken] = await Promise.all([
|
||||
this.firebaseInstallations.getId(),
|
||||
this.firebaseInstallations.getToken()
|
||||
]);
|
||||
|
||||
const urlBase =
|
||||
window.FIREBASE_REMOTE_CONFIG_URL_BASE ||
|
||||
'https://firebaseremoteconfig.googleapis.com';
|
||||
|
||||
const url = `${urlBase}/v1/projects/${this.projectId}/namespaces/${this.namespace}:fetch?key=${this.apiKey}`;
|
||||
|
||||
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 || '*'
|
||||
};
|
||||
|
||||
const requestBody: FetchRequestBody = {
|
||||
/* eslint-disable camelcase */
|
||||
sdk_version: this.sdkVersion,
|
||||
app_instance_id: installationId,
|
||||
app_instance_id_token: installationToken,
|
||||
app_id: this.appId,
|
||||
language_code: getUserLanguage()
|
||||
/* eslint-enable camelcase */
|
||||
};
|
||||
|
||||
const options = {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify(requestBody)
|
||||
};
|
||||
|
||||
// This logic isn't REST-specific, but shimming abort logic isn't worth another decorator.
|
||||
const fetchPromise = fetch(url, options);
|
||||
const timeoutPromise = new Promise((_resolve, reject) => {
|
||||
// Maps async event listener to Promise API.
|
||||
request.signal.addEventListener(() => {
|
||||
// Emulates https://heycam.github.io/webidl/#aborterror
|
||||
const error = new Error('The operation was aborted.');
|
||||
error.name = 'AbortError';
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
|
||||
let response;
|
||||
try {
|
||||
await Promise.race([fetchPromise, timeoutPromise]);
|
||||
response = await fetchPromise;
|
||||
} catch (originalError) {
|
||||
let errorCode = ErrorCode.FETCH_NETWORK;
|
||||
if (originalError.name === 'AbortError') {
|
||||
errorCode = ErrorCode.FETCH_TIMEOUT;
|
||||
}
|
||||
throw ERROR_FACTORY.create(errorCode, {
|
||||
originalErrorMessage: originalError.message
|
||||
});
|
||||
}
|
||||
|
||||
let status = response.status;
|
||||
|
||||
// Normalizes nullable header to optional.
|
||||
const responseEtag = response.headers.get('ETag') || undefined;
|
||||
|
||||
let config: FirebaseRemoteConfigObject | undefined;
|
||||
let state: string | 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.
|
||||
if (response.status === 200) {
|
||||
let responseBody;
|
||||
try {
|
||||
responseBody = await response.json();
|
||||
} catch (originalError) {
|
||||
throw ERROR_FACTORY.create(ErrorCode.FETCH_PARSE, {
|
||||
originalErrorMessage: originalError.message
|
||||
});
|
||||
}
|
||||
config = responseBody['entries'];
|
||||
state = responseBody['state'];
|
||||
}
|
||||
|
||||
// Normalizes based on legacy state.
|
||||
if (state === 'INSTANCE_STATE_UNSPECIFIED') {
|
||||
status = 500;
|
||||
} else if (state === 'NO_CHANGE') {
|
||||
status = 304;
|
||||
} else if (state === 'NO_TEMPLATE' || state === 'EMPTY_CONFIG') {
|
||||
// These cases can be fixed remotely, so normalize to safe value.
|
||||
config = {};
|
||||
}
|
||||
|
||||
// Normalize to exception-based control flow for non-success cases.
|
||||
// Encapsulates HTTP specifics in this class as much as possible. Status is still the best for
|
||||
// differentiating success states (200 from 304; the state body param is undefined in a
|
||||
// standard 304).
|
||||
if (status !== 304 && status !== 200) {
|
||||
throw ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus: status
|
||||
});
|
||||
}
|
||||
|
||||
return { status, eTag: responseEtag, config };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 {
|
||||
RemoteConfigAbortSignal,
|
||||
RemoteConfigFetchClient,
|
||||
FetchResponse,
|
||||
FetchRequest
|
||||
} from './remote_config_fetch_client';
|
||||
import { ThrottleMetadata, Storage } from '../storage/storage';
|
||||
import { calculateBackoffMillis } from './exponential_backoff';
|
||||
import { ErrorCode, ERROR_FACTORY } from '../errors';
|
||||
import { FirebaseError } from '@firebase/util';
|
||||
|
||||
/**
|
||||
* Supports waiting on a backoff by:
|
||||
*
|
||||
* <ul>
|
||||
* <li>Promisifying setTimeout, so we can set a timeout in our Promise chain</li>
|
||||
* <li>Listening on a signal bus for abort events, just like the Fetch API</li>
|
||||
* <li>Failing in the same way the Fetch API fails, so timing out a live request and a throttled
|
||||
* request appear the same.</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p>Visible for testing.
|
||||
*/
|
||||
export function setAbortableTimeout(
|
||||
signal: RemoteConfigAbortSignal,
|
||||
throttleEndTimeMillis: number
|
||||
): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
// Derives backoff from given end time, normalizing negative numbers to zero.
|
||||
const backoffMillis = Math.max(throttleEndTimeMillis - Date.now(), 0);
|
||||
|
||||
const timeout = setTimeout(resolve, backoffMillis);
|
||||
|
||||
// Adds listener, rather than sets onabort, because signal is a shared object.
|
||||
signal.addEventListener(() => {
|
||||
clearTimeout(timeout);
|
||||
|
||||
// If the request completes before this timeout, the rejection has no effect.
|
||||
reject(
|
||||
ERROR_FACTORY.create(ErrorCode.FETCH_THROTTLE, {
|
||||
throttleEndTimeMillis
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the {@link Error} indicates a fetch request may succeed later.
|
||||
*/
|
||||
function isRetriableError(e: Error): boolean {
|
||||
if (!(e instanceof FirebaseError)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Uses string index defined by ErrorData, which FirebaseError implements.
|
||||
const httpStatus = Number(e['httpStatus']);
|
||||
|
||||
return (
|
||||
httpStatus === 429 ||
|
||||
httpStatus === 500 ||
|
||||
httpStatus === 503 ||
|
||||
httpStatus === 504
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorates a Client with retry logic.
|
||||
*
|
||||
* <p>Comparable to CachingClient, but uses backoff logic instead of cache max age and doesn't cache
|
||||
* responses (because the SDK has no use for error responses).
|
||||
*/
|
||||
export class RetryingClient implements RemoteConfigFetchClient {
|
||||
constructor(
|
||||
private readonly client: RemoteConfigFetchClient,
|
||||
private readonly storage: Storage
|
||||
) {}
|
||||
|
||||
async fetch(request: FetchRequest): Promise<FetchResponse> {
|
||||
const throttleMetadata = (await this.storage.getThrottleMetadata()) || {
|
||||
backoffCount: 0,
|
||||
throttleEndTimeMillis: Date.now()
|
||||
};
|
||||
|
||||
return this.attemptFetch(request, throttleMetadata);
|
||||
}
|
||||
|
||||
/**
|
||||
* A recursive helper for attempting a fetch request repeatedly.
|
||||
*
|
||||
* @throws any non-retriable errors.
|
||||
*/
|
||||
async attemptFetch(
|
||||
request: FetchRequest,
|
||||
{ throttleEndTimeMillis, backoffCount }: ThrottleMetadata
|
||||
): Promise<FetchResponse> {
|
||||
// Starts with a (potentially zero) timeout to support resumption from stored state.
|
||||
// Ensures the throttle end time is honored if the last attempt timed out.
|
||||
// Note the SDK will never make a request if the fetch timeout expires at this point.
|
||||
await setAbortableTimeout(request.signal, throttleEndTimeMillis);
|
||||
|
||||
try {
|
||||
const response = await this.client.fetch(request);
|
||||
|
||||
// Note the SDK only clears throttle state if response is success or non-retriable.
|
||||
await this.storage.deleteThrottleMetadata();
|
||||
|
||||
return response;
|
||||
} catch (e) {
|
||||
if (!isRetriableError(e)) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
// Increments backoff state.
|
||||
const throttleMetadata = {
|
||||
throttleEndTimeMillis:
|
||||
Date.now() + calculateBackoffMillis(backoffCount),
|
||||
backoffCount: backoffCount + 1
|
||||
};
|
||||
|
||||
// Persists state.
|
||||
await this.storage.setThrottleMetadata(throttleMetadata);
|
||||
|
||||
return this.attemptFetch(request, throttleMetadata);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { ErrorFactory, FirebaseError } from '@firebase/util';
|
||||
|
||||
export const enum ErrorCode {
|
||||
REGISTRATION_WINDOW = 'registration-window',
|
||||
REGISTRATION_PROJECT_ID = 'registration-project-id',
|
||||
REGISTRATION_API_KEY = 'registration-api-key',
|
||||
REGISTRATION_APP_ID = 'registration-app-id',
|
||||
STORAGE_OPEN = 'storage-open',
|
||||
STORAGE_GET = 'storage-get',
|
||||
STORAGE_SET = 'storage-set',
|
||||
STORAGE_DELETE = 'storage-delete',
|
||||
FETCH_NETWORK = 'fetch-client-network',
|
||||
FETCH_TIMEOUT = 'fetch-timeout',
|
||||
FETCH_THROTTLE = 'fetch-throttle',
|
||||
FETCH_PARSE = 'fetch-client-parse',
|
||||
FETCH_STATUS = 'fetch-status'
|
||||
}
|
||||
|
||||
const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = {
|
||||
[ErrorCode.REGISTRATION_WINDOW]:
|
||||
'Undefined window object. This SDK only supports usage in a browser environment.',
|
||||
[ErrorCode.REGISTRATION_PROJECT_ID]:
|
||||
'Undefined project identifier. Check Firebase app initialization.',
|
||||
[ErrorCode.REGISTRATION_API_KEY]:
|
||||
'Undefined API key. Check Firebase app initialization.',
|
||||
[ErrorCode.REGISTRATION_APP_ID]:
|
||||
'Undefined app identifier. Check Firebase app initialization.',
|
||||
[ErrorCode.STORAGE_OPEN]:
|
||||
'Error thrown when opening storage. Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.STORAGE_GET]:
|
||||
'Error thrown when reading from storage. Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.STORAGE_SET]:
|
||||
'Error thrown when writing to storage. Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.STORAGE_DELETE]:
|
||||
'Error thrown when deleting from storage. Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.FETCH_NETWORK]:
|
||||
'Fetch client failed to connect to a network. Check Internet connection.' +
|
||||
' Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.FETCH_TIMEOUT]:
|
||||
'The config fetch request timed out. ' +
|
||||
' Configure timeout using "fetchTimeoutMillis" SDK setting.',
|
||||
[ErrorCode.FETCH_THROTTLE]:
|
||||
'The config fetch request timed out while in an exponential backoff state.' +
|
||||
' Configure timeout using "fetchTimeoutMillis" SDK setting.' +
|
||||
' Unix timestamp in milliseconds when fetch request throttling ends: {$throttleEndTimeMillis}.',
|
||||
[ErrorCode.FETCH_PARSE]:
|
||||
'Fetch client could not parse response.' +
|
||||
' Original error: {$originalErrorMessage}.',
|
||||
[ErrorCode.FETCH_STATUS]:
|
||||
'Fetch server returned an HTTP error status. HTTP status: {$httpStatus}.'
|
||||
};
|
||||
|
||||
// Note this is effectively a type system binding a code to params. This approach overlaps with the
|
||||
// role of TS interfaces, but works well for a few reasons:
|
||||
// 1) JS is unaware of TS interfaces, eg we can't test for interface implementation in JS
|
||||
// 2) callers should have access to a human-readable summary of the error and this interpolates
|
||||
// params into an error message;
|
||||
// 3) callers should be able to programmatically access data associated with an error, which
|
||||
// ErrorData provides.
|
||||
interface ErrorParams {
|
||||
[ErrorCode.STORAGE_OPEN]: { originalErrorMessage: string | undefined };
|
||||
[ErrorCode.STORAGE_GET]: { originalErrorMessage: string | undefined };
|
||||
[ErrorCode.STORAGE_SET]: { originalErrorMessage: string | undefined };
|
||||
[ErrorCode.STORAGE_DELETE]: { originalErrorMessage: string | undefined };
|
||||
[ErrorCode.FETCH_NETWORK]: { originalErrorMessage: string };
|
||||
[ErrorCode.FETCH_THROTTLE]: { throttleEndTimeMillis: number };
|
||||
[ErrorCode.FETCH_PARSE]: { originalErrorMessage: string };
|
||||
[ErrorCode.FETCH_STATUS]: { httpStatus: number };
|
||||
}
|
||||
|
||||
export const ERROR_FACTORY = new ErrorFactory<ErrorCode, ErrorParams>(
|
||||
'remoteconfig' /* service */,
|
||||
'Remote Config' /* service name */,
|
||||
ERROR_DESCRIPTION_MAP
|
||||
);
|
||||
|
||||
// Note how this is like typeof/instanceof, but for ErrorCode.
|
||||
export function hasErrorCode(e: Error, errorCode: ErrorCode): boolean {
|
||||
return e instanceof FirebaseError && e.code.indexOf(errorCode) !== -1;
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Attempts to get the most accurate browser language setting.
|
||||
*
|
||||
* <p>Adapted from getUserLanguage in packages/auth/src/utils.js for TypeScript.
|
||||
*
|
||||
* <p>Defers default language specification to server logic for consistency.
|
||||
*
|
||||
* @param navigatorLanguage Enables tests to override read-only {@link NavigatorLanguage}.
|
||||
*/
|
||||
export function getUserLanguage(
|
||||
navigatorLanguage: NavigatorLanguage = navigator
|
||||
): string {
|
||||
return (
|
||||
// Most reliable, but only supported in Chrome/Firefox.
|
||||
(navigatorLanguage.languages && navigatorLanguage.languages[0]) ||
|
||||
// Supported in most browsers, but returns the language of the browser
|
||||
// UI, not the language set in browser settings.
|
||||
navigatorLanguage.language
|
||||
// Polyfill otherwise.
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { FirebaseApp } from '@firebase/app-types';
|
||||
import {
|
||||
RemoteConfig as RemoteConfigType,
|
||||
FetchStatus,
|
||||
Settings,
|
||||
Value as ValueType,
|
||||
LogLevel as RemoteConfigLogLevel
|
||||
} from '@firebase/remote-config-types';
|
||||
import { StorageCache } from './storage/storage_cache';
|
||||
import {
|
||||
RemoteConfigFetchClient,
|
||||
RemoteConfigAbortSignal
|
||||
} from './client/remote_config_fetch_client';
|
||||
import { Value } from './value';
|
||||
import { ErrorCode, hasErrorCode } from './errors';
|
||||
import { Storage } from './storage/storage';
|
||||
import { Logger, LogLevel as FirebaseLogLevel } from '@firebase/logger';
|
||||
|
||||
const DEFAULT_FETCH_TIMEOUT_MILLIS = 60 * 1000; // One minute
|
||||
const DEFAULT_CACHE_MAX_AGE_MILLIS = 12 * 60 * 60 * 1000; // Twelve hours.
|
||||
|
||||
/**
|
||||
* Encapsulates business logic mapping network and storage dependencies to the public SDK API.
|
||||
*
|
||||
* See {@link https://github.com/FirebasePrivate/firebase-js-sdk/blob/master/packages/firebase/index.d.ts|interface documentation} for method descriptions.
|
||||
*/
|
||||
export class RemoteConfig implements RemoteConfigType {
|
||||
// Tracks completion of initialization promise.
|
||||
private _isInitializationComplete = false;
|
||||
|
||||
// De-duplicates initialization calls.
|
||||
private _initializePromise?: Promise<void>;
|
||||
|
||||
settings: Settings = {
|
||||
fetchTimeoutMillis: DEFAULT_FETCH_TIMEOUT_MILLIS,
|
||||
minimumFetchIntervalMillis: DEFAULT_CACHE_MAX_AGE_MILLIS
|
||||
};
|
||||
|
||||
defaultConfig: { [key: string]: string | number | boolean } = {};
|
||||
|
||||
// Based on packages/firestore/src/util/log.ts but not static because we need per-instance levels
|
||||
// to differentiate 2p and 3p use-cases.
|
||||
setLogLevel(logLevel: RemoteConfigLogLevel): void {
|
||||
switch (logLevel) {
|
||||
case 'debug':
|
||||
this._logger.logLevel = FirebaseLogLevel.DEBUG;
|
||||
break;
|
||||
case 'silent':
|
||||
this._logger.logLevel = FirebaseLogLevel.SILENT;
|
||||
break;
|
||||
default:
|
||||
this._logger.logLevel = FirebaseLogLevel.ERROR;
|
||||
}
|
||||
}
|
||||
|
||||
get fetchTimeMillis(): number {
|
||||
return this._storageCache.getLastSuccessfulFetchTimestampMillis() || -1;
|
||||
}
|
||||
|
||||
get lastFetchStatus(): FetchStatus {
|
||||
return this._storageCache.getLastFetchStatus() || 'no-fetch-yet';
|
||||
}
|
||||
|
||||
constructor(
|
||||
// Required by FirebaseServiceFactory interface.
|
||||
readonly app: FirebaseApp,
|
||||
// JS doesn't support private yet
|
||||
// (https://github.com/tc39/proposal-class-fields#private-fields), so we hint using an
|
||||
// underscore prefix.
|
||||
private readonly _client: RemoteConfigFetchClient,
|
||||
private readonly _storageCache: StorageCache,
|
||||
private readonly _storage: Storage,
|
||||
private readonly _logger: Logger
|
||||
) {}
|
||||
|
||||
async activate(): Promise<boolean> {
|
||||
const [lastSuccessfulFetchResponse, activeConfigEtag] = await Promise.all([
|
||||
this._storage.getLastSuccessfulFetchResponse(),
|
||||
this._storage.getActiveConfigEtag()
|
||||
]);
|
||||
if (
|
||||
!lastSuccessfulFetchResponse ||
|
||||
!lastSuccessfulFetchResponse.config ||
|
||||
!lastSuccessfulFetchResponse.eTag ||
|
||||
lastSuccessfulFetchResponse.eTag === activeConfigEtag
|
||||
) {
|
||||
// Either there is no successful fetched config, or is the same as current active
|
||||
// config.
|
||||
return false;
|
||||
}
|
||||
await Promise.all([
|
||||
this._storageCache.setActiveConfig(lastSuccessfulFetchResponse.config),
|
||||
this._storage.setActiveConfigEtag(lastSuccessfulFetchResponse.eTag)
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
ensureInitialized(): Promise<void> {
|
||||
if (!this._initializePromise) {
|
||||
this._initializePromise = this._storageCache
|
||||
.loadFromStorage()
|
||||
.then(() => {
|
||||
this._isInitializationComplete = true;
|
||||
});
|
||||
}
|
||||
return this._initializePromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws a {@link ErrorCode.FETCH_CLIENT_TIMEOUT} if the request takes longer than
|
||||
* {@link Settings.fetchTimeoutInSeconds} or
|
||||
* {@link DEFAULT_FETCH_TIMEOUT_SECONDS}.
|
||||
*/
|
||||
async fetch(): Promise<void> {
|
||||
return new Promise(async (resolve, reject) => {
|
||||
// Aborts the request after the given timeout, causing the fetch call to
|
||||
// reject with an AbortError.
|
||||
//
|
||||
// <p>Aborting after the request completes is a no-op, so we don't need a
|
||||
// corresponding clearTimeout.
|
||||
//
|
||||
// Locating abort logic here because:
|
||||
// * it uses a developer setting (timeout)
|
||||
// * it applies to all retries (like curl's max-time arg)
|
||||
// * it is consistent with the Fetch API's signal input
|
||||
const abortSignal = new RemoteConfigAbortSignal();
|
||||
|
||||
setTimeout(async () => {
|
||||
// Note a very low delay, eg < 10ms, can elapse before listeners are initialized.
|
||||
abortSignal.abort();
|
||||
}, this.settings.fetchTimeoutMillis);
|
||||
|
||||
// Catches *all* errors thrown by client so status can be set consistently.
|
||||
try {
|
||||
await this._client.fetch({
|
||||
cacheMaxAgeMillis: this.settings.minimumFetchIntervalMillis,
|
||||
signal: abortSignal
|
||||
});
|
||||
|
||||
await this._storageCache.setLastFetchStatus('success');
|
||||
resolve();
|
||||
} catch (e) {
|
||||
const lastFetchStatus = hasErrorCode(e, ErrorCode.FETCH_THROTTLE)
|
||||
? 'throttle'
|
||||
: 'failure';
|
||||
await this._storageCache.setLastFetchStatus(lastFetchStatus);
|
||||
reject(e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async fetchAndActivate(): Promise<boolean> {
|
||||
await this.fetch();
|
||||
return this.activate();
|
||||
}
|
||||
|
||||
getAll(): { [key: string]: ValueType } {
|
||||
return getAllKeys(
|
||||
this._storageCache.getActiveConfig(),
|
||||
this.defaultConfig
|
||||
).reduce(
|
||||
(allConfigs, key) => {
|
||||
allConfigs[key] = this.getValue(key);
|
||||
return allConfigs;
|
||||
},
|
||||
{} as { [key: string]: ValueType }
|
||||
);
|
||||
}
|
||||
|
||||
getBoolean(key: string): boolean {
|
||||
return this.getValue(key).asBoolean();
|
||||
}
|
||||
|
||||
getNumber(key: string): number {
|
||||
return this.getValue(key).asNumber();
|
||||
}
|
||||
|
||||
getString(key: string): string {
|
||||
return this.getValue(key).asString();
|
||||
}
|
||||
|
||||
getValue(key: string): ValueType {
|
||||
if (!this._isInitializationComplete) {
|
||||
this._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.'
|
||||
);
|
||||
}
|
||||
const activeConfig = this._storageCache.getActiveConfig();
|
||||
if (activeConfig && activeConfig[key] !== undefined) {
|
||||
return new Value('remote', activeConfig[key]);
|
||||
} else if (this.defaultConfig && this.defaultConfig[key] !== undefined) {
|
||||
return new Value('default', String(this.defaultConfig[key]));
|
||||
}
|
||||
this._logger.debug(
|
||||
`Returning static value for key "${key}".` +
|
||||
' Define a default or remote value if this is unintentional.'
|
||||
);
|
||||
return new Value('static');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Dedupes and returns an array of all the keys of the received objects.
|
||||
*/
|
||||
function getAllKeys(obj1: {} = {}, obj2: {} = {}): string[] {
|
||||
return Object.keys({ ...obj1, ...obj2 });
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { FetchStatus } from '@firebase/remote-config-types';
|
||||
import {
|
||||
FetchResponse,
|
||||
FirebaseRemoteConfigObject
|
||||
} from '../client/remote_config_fetch_client';
|
||||
import { ERROR_FACTORY, ErrorCode } from '../errors';
|
||||
import { FirebaseError } from '@firebase/util';
|
||||
|
||||
/**
|
||||
* Converts an error event associated with a {@link IDBRequest} to a {@link FirebaseError}.
|
||||
*/
|
||||
function toFirebaseError(event: Event, errorCode: ErrorCode): FirebaseError {
|
||||
const originalError = (event.target as IDBRequest).error || undefined;
|
||||
return ERROR_FACTORY.create(errorCode, {
|
||||
originalErrorMessage: originalError && originalError.message
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* A general-purpose store keyed by app + namespace + {@link
|
||||
* ProjectNamespaceKeyFieldValue}.
|
||||
*
|
||||
* <p>The Remote Config SDK can be used with multiple app installations, and each app can interact
|
||||
* with multiple namespaces, so this store uses app (ID + name) and namespace as common parent keys
|
||||
* for a set of key-value pairs. See {@link Storage#createCompositeKey}.
|
||||
*
|
||||
* <p>Visible for testing.
|
||||
*/
|
||||
export const APP_NAMESPACE_STORE = 'app_namespace_store';
|
||||
|
||||
const DB_NAME = 'firebase_remote_config';
|
||||
const DB_VERSION = 1;
|
||||
|
||||
/**
|
||||
* Encapsulates metadata concerning throttled fetch requests.
|
||||
*/
|
||||
export interface ThrottleMetadata {
|
||||
// The number of times fetch has backed off. Used for resuming backoff after a timeout.
|
||||
backoffCount: number;
|
||||
// The Unix timestamp in milliseconds when callers can retry a request.
|
||||
throttleEndTimeMillis: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Provides type-safety for the "key" field used by {@link APP_NAMESPACE_STORE}.
|
||||
*
|
||||
* <p>This seems like a small price to avoid potentially subtle bugs caused by a typo.
|
||||
*/
|
||||
type ProjectNamespaceKeyFieldValue =
|
||||
| 'active_config'
|
||||
| 'active_config_etag'
|
||||
| 'last_fetch_status'
|
||||
| 'last_successful_fetch_timestamp_millis'
|
||||
| 'last_successful_fetch_response'
|
||||
| 'settings'
|
||||
| 'throttle_metadata';
|
||||
|
||||
// Visible for testing.
|
||||
export function openDatabase(): Promise<IDBDatabase> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION);
|
||||
request.onerror = event => {
|
||||
reject(toFirebaseError(event, ErrorCode.STORAGE_OPEN));
|
||||
};
|
||||
request.onsuccess = event => {
|
||||
resolve((event.target as IDBOpenDBRequest).result);
|
||||
};
|
||||
request.onupgradeneeded = event => {
|
||||
const db = (event.target as IDBOpenDBRequest).result;
|
||||
|
||||
// We don't use 'break' in this switch statement, the fall-through
|
||||
// behavior is what we want, because if there are multiple versions between
|
||||
// the old version and the current version, we want ALL the migrations
|
||||
// that correspond to those versions to run, not only the last one.
|
||||
// eslint-disable-next-line default-case
|
||||
switch (event.oldVersion) {
|
||||
case 0:
|
||||
db.createObjectStore(APP_NAMESPACE_STORE, {
|
||||
keyPath: 'compositeKey'
|
||||
});
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 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()
|
||||
) {}
|
||||
|
||||
getLastFetchStatus(): Promise<FetchStatus | undefined> {
|
||||
return this.get<FetchStatus>('last_fetch_status');
|
||||
}
|
||||
|
||||
setLastFetchStatus(status: FetchStatus): Promise<void> {
|
||||
return this.set<FetchStatus>('last_fetch_status', status);
|
||||
}
|
||||
|
||||
// This is comparable to a cache entry timestamp. If we need to expire other data, we could
|
||||
// consider adding timestamp to all storage records and an optional max age arg to getters.
|
||||
getLastSuccessfulFetchTimestampMillis(): Promise<number | undefined> {
|
||||
return this.get<number>('last_successful_fetch_timestamp_millis');
|
||||
}
|
||||
|
||||
setLastSuccessfulFetchTimestampMillis(timestamp: number): Promise<void> {
|
||||
return this.set<number>(
|
||||
'last_successful_fetch_timestamp_millis',
|
||||
timestamp
|
||||
);
|
||||
}
|
||||
|
||||
getLastSuccessfulFetchResponse(): Promise<FetchResponse | undefined> {
|
||||
return this.get<FetchResponse>('last_successful_fetch_response');
|
||||
}
|
||||
|
||||
setLastSuccessfulFetchResponse(response: FetchResponse): Promise<void> {
|
||||
return this.set<FetchResponse>('last_successful_fetch_response', response);
|
||||
}
|
||||
|
||||
getActiveConfig(): Promise<FirebaseRemoteConfigObject | undefined> {
|
||||
return this.get<FirebaseRemoteConfigObject>('active_config');
|
||||
}
|
||||
|
||||
setActiveConfig(config: FirebaseRemoteConfigObject): Promise<void> {
|
||||
return this.set<FirebaseRemoteConfigObject>('active_config', config);
|
||||
}
|
||||
|
||||
getActiveConfigEtag(): Promise<string | undefined> {
|
||||
return this.get<string>('active_config_etag');
|
||||
}
|
||||
|
||||
setActiveConfigEtag(etag: string): Promise<void> {
|
||||
return this.set<string>('active_config_etag', etag);
|
||||
}
|
||||
|
||||
getThrottleMetadata(): Promise<ThrottleMetadata | undefined> {
|
||||
return this.get<ThrottleMetadata>('throttle_metadata');
|
||||
}
|
||||
|
||||
setThrottleMetadata(metadata: ThrottleMetadata): Promise<void> {
|
||||
return this.set<ThrottleMetadata>('throttle_metadata', metadata);
|
||||
}
|
||||
|
||||
deleteThrottleMetadata(): Promise<void> {
|
||||
return this.delete('throttle_metadata');
|
||||
}
|
||||
|
||||
async get<T>(key: ProjectNamespaceKeyFieldValue): Promise<T | undefined> {
|
||||
const db = await this.openDbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([APP_NAMESPACE_STORE], 'readonly');
|
||||
const objectStore = transaction.objectStore(APP_NAMESPACE_STORE);
|
||||
const compositeKey = this.createCompositeKey(key);
|
||||
try {
|
||||
const request = objectStore.get(compositeKey);
|
||||
request.onerror = event => {
|
||||
reject(toFirebaseError(event, ErrorCode.STORAGE_GET));
|
||||
};
|
||||
request.onsuccess = event => {
|
||||
const result = (event.target as IDBRequest).result;
|
||||
if (result) {
|
||||
resolve(result.value);
|
||||
} else {
|
||||
resolve(undefined);
|
||||
}
|
||||
};
|
||||
} catch (e) {
|
||||
reject(
|
||||
ERROR_FACTORY.create(ErrorCode.STORAGE_GET, {
|
||||
originalErrorMessage: e && e.message
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async set<T>(key: ProjectNamespaceKeyFieldValue, value: T): Promise<void> {
|
||||
const db = await this.openDbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite');
|
||||
const objectStore = transaction.objectStore(APP_NAMESPACE_STORE);
|
||||
const compositeKey = this.createCompositeKey(key);
|
||||
try {
|
||||
const request = objectStore.put({
|
||||
compositeKey,
|
||||
value
|
||||
});
|
||||
request.onerror = (event: Event) => {
|
||||
reject(toFirebaseError(event, ErrorCode.STORAGE_SET));
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (e) {
|
||||
reject(
|
||||
ERROR_FACTORY.create(ErrorCode.STORAGE_SET, {
|
||||
originalErrorMessage: e && e.message
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async delete(key: ProjectNamespaceKeyFieldValue): Promise<void> {
|
||||
const db = await this.openDbPromise;
|
||||
return new Promise((resolve, reject) => {
|
||||
const transaction = db.transaction([APP_NAMESPACE_STORE], 'readwrite');
|
||||
const objectStore = transaction.objectStore(APP_NAMESPACE_STORE);
|
||||
const compositeKey = this.createCompositeKey(key);
|
||||
try {
|
||||
const request = objectStore.delete(compositeKey);
|
||||
request.onerror = (event: Event) => {
|
||||
reject(toFirebaseError(event, ErrorCode.STORAGE_DELETE));
|
||||
};
|
||||
request.onsuccess = () => {
|
||||
resolve();
|
||||
};
|
||||
} catch (e) {
|
||||
reject(
|
||||
ERROR_FACTORY.create(ErrorCode.STORAGE_DELETE, {
|
||||
originalErrorMessage: e && e.message
|
||||
})
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Facilitates composite key functionality (which is unsupported in IE).
|
||||
createCompositeKey(key: ProjectNamespaceKeyFieldValue): string {
|
||||
return [this.appId, this.appName, this.namespace, key].join();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { FetchStatus } from '@firebase/remote-config-types';
|
||||
import { FirebaseRemoteConfigObject } from '../client/remote_config_fetch_client';
|
||||
import { Storage } from './storage';
|
||||
|
||||
/**
|
||||
* A memory cache layer over storage to support the SDK's synchronous read requirements.
|
||||
*/
|
||||
export class StorageCache {
|
||||
constructor(private readonly storage: Storage) {}
|
||||
|
||||
/**
|
||||
* Memory caches.
|
||||
*/
|
||||
private lastFetchStatus?: FetchStatus;
|
||||
private lastSuccessfulFetchTimestampMillis?: number;
|
||||
private activeConfig?: FirebaseRemoteConfigObject;
|
||||
|
||||
/**
|
||||
* Memory-only getters
|
||||
*/
|
||||
getLastFetchStatus(): FetchStatus | undefined {
|
||||
return this.lastFetchStatus;
|
||||
}
|
||||
|
||||
getLastSuccessfulFetchTimestampMillis(): number | undefined {
|
||||
return this.lastSuccessfulFetchTimestampMillis;
|
||||
}
|
||||
|
||||
getActiveConfig(): FirebaseRemoteConfigObject | undefined {
|
||||
return this.activeConfig;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read-ahead getter
|
||||
*/
|
||||
async loadFromStorage(): Promise<void> {
|
||||
const lastFetchStatusPromise = this.storage.getLastFetchStatus();
|
||||
const lastSuccessfulFetchTimestampMillisPromise = this.storage.getLastSuccessfulFetchTimestampMillis();
|
||||
const activeConfigPromise = this.storage.getActiveConfig();
|
||||
|
||||
// Note:
|
||||
// 1. we consistently check for undefined to avoid clobbering defined values
|
||||
// in memory
|
||||
// 2. we defer awaiting to improve readability, as opposed to destructuring
|
||||
// a Promise.all result, for example
|
||||
|
||||
const lastFetchStatus = await lastFetchStatusPromise;
|
||||
if (lastFetchStatus) {
|
||||
this.lastFetchStatus = lastFetchStatus;
|
||||
}
|
||||
|
||||
const lastSuccessfulFetchTimestampMillis = await lastSuccessfulFetchTimestampMillisPromise;
|
||||
if (lastSuccessfulFetchTimestampMillis) {
|
||||
this.lastSuccessfulFetchTimestampMillis = lastSuccessfulFetchTimestampMillis;
|
||||
}
|
||||
|
||||
const activeConfig = await activeConfigPromise;
|
||||
if (activeConfig) {
|
||||
this.activeConfig = activeConfig;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write-through setters
|
||||
*/
|
||||
setLastFetchStatus(status: FetchStatus): Promise<void> {
|
||||
this.lastFetchStatus = status;
|
||||
return this.storage.setLastFetchStatus(status);
|
||||
}
|
||||
|
||||
setLastSuccessfulFetchTimestampMillis(
|
||||
timestampMillis: number
|
||||
): Promise<void> {
|
||||
this.lastSuccessfulFetchTimestampMillis = timestampMillis;
|
||||
return this.storage.setLastSuccessfulFetchTimestampMillis(timestampMillis);
|
||||
}
|
||||
|
||||
setActiveConfig(activeConfig: FirebaseRemoteConfigObject): Promise<void> {
|
||||
this.activeConfig = activeConfig;
|
||||
return this.storage.setActiveConfig(activeConfig);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { Value as ValueType, ValueSource } from '@firebase/remote-config-types';
|
||||
|
||||
const DEFAULT_VALUE_FOR_BOOLEAN = false;
|
||||
const DEFAULT_VALUE_FOR_STRING = '';
|
||||
const DEFAULT_VALUE_FOR_NUMBER = 0;
|
||||
|
||||
const BOOLEAN_TRUTHY_VALUES = ['1', 'true', 't', 'yes', 'y', 'on'];
|
||||
|
||||
export class Value implements ValueType {
|
||||
constructor(
|
||||
private readonly _source: ValueSource,
|
||||
private readonly _value: string = DEFAULT_VALUE_FOR_STRING
|
||||
) {}
|
||||
|
||||
asString(): string {
|
||||
return this._value;
|
||||
}
|
||||
|
||||
asBoolean(): boolean {
|
||||
if (this._source === 'static') {
|
||||
return DEFAULT_VALUE_FOR_BOOLEAN;
|
||||
}
|
||||
return BOOLEAN_TRUTHY_VALUES.indexOf(this._value.toLowerCase()) >= 0;
|
||||
}
|
||||
|
||||
asNumber(): number {
|
||||
if (this._source === 'static') {
|
||||
return DEFAULT_VALUE_FOR_NUMBER;
|
||||
}
|
||||
let num = Number(this._value);
|
||||
if (isNaN(num)) {
|
||||
num = DEFAULT_VALUE_FOR_NUMBER;
|
||||
}
|
||||
return num;
|
||||
}
|
||||
|
||||
getSource(): ValueSource {
|
||||
return this._source;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 '../setup';
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
RemoteConfigFetchClient,
|
||||
FetchResponse,
|
||||
FetchRequest,
|
||||
RemoteConfigAbortSignal
|
||||
} from '../../src/client/remote_config_fetch_client';
|
||||
import * as sinon from 'sinon';
|
||||
import { CachingClient } from '../../src/client/caching_client';
|
||||
import { StorageCache } from '../../src/storage/storage_cache';
|
||||
import { Storage } from '../../src/storage/storage';
|
||||
import { Logger } from '@firebase/logger';
|
||||
|
||||
const DEFAULT_REQUEST: FetchRequest = {
|
||||
// Invalidates cache by default.
|
||||
cacheMaxAgeMillis: 0,
|
||||
signal: new RemoteConfigAbortSignal()
|
||||
};
|
||||
|
||||
describe('CachingClient', () => {
|
||||
const backingClient = {} as RemoteConfigFetchClient;
|
||||
const storageCache = {} as StorageCache;
|
||||
const logger = {} as Logger;
|
||||
const storage = {} as Storage;
|
||||
let cachingClient: CachingClient;
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
beforeEach(() => {
|
||||
logger.debug = sinon.stub();
|
||||
cachingClient = new CachingClient(
|
||||
backingClient,
|
||||
storage,
|
||||
storageCache,
|
||||
logger
|
||||
);
|
||||
clock = sinon.useFakeTimers({ now: 3000 }); // Mocks Date.now as 3000.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
describe('isCacheDataFresh', () => {
|
||||
it('returns false if cached response is older than max age', () => {
|
||||
expect(
|
||||
cachingClient.isCachedDataFresh(
|
||||
// Mocks a cache set when Date.now was 1000, ie it's two seconds old.
|
||||
1000,
|
||||
// Tolerates a cache one second old.
|
||||
1000
|
||||
)
|
||||
).to.be.false;
|
||||
});
|
||||
|
||||
it('returns true if cached response is equal to max age', () => {
|
||||
expect(cachingClient.isCachedDataFresh(2000, 1000)).to.be.true;
|
||||
});
|
||||
|
||||
it('returns true if cached response is younger than max age', () => {
|
||||
expect(cachingClient.isCachedDataFresh(3000, 1000)).to.be.true;
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
beforeEach(() => {
|
||||
storage.getLastSuccessfulFetchTimestampMillis = sinon
|
||||
.stub()
|
||||
.returns(1000); // Mocks a cache set when Date.now was 1000, ie it's two seconds old.
|
||||
storageCache.setLastSuccessfulFetchTimestampMillis = sinon.stub();
|
||||
storage.getLastSuccessfulFetchResponse = sinon.stub();
|
||||
storage.setLastSuccessfulFetchResponse = sinon.stub();
|
||||
backingClient.fetch = sinon.stub().returns(Promise.resolve({}));
|
||||
});
|
||||
|
||||
it('exits early on cache hit', async () => {
|
||||
const expectedResponse = { config: { eTag: 'etag', color: 'taupe' } };
|
||||
storage.getLastSuccessfulFetchResponse = sinon
|
||||
.stub()
|
||||
.returns(expectedResponse);
|
||||
|
||||
const actualResponse = await cachingClient.fetch({
|
||||
cacheMaxAgeMillis: 2000,
|
||||
signal: new RemoteConfigAbortSignal()
|
||||
});
|
||||
|
||||
expect(actualResponse).to.deep.eq(expectedResponse);
|
||||
expect(backingClient.fetch).not.to.have.been.called;
|
||||
});
|
||||
|
||||
it('fetches on cache miss', async () => {
|
||||
await cachingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(backingClient.fetch).to.have.been.called;
|
||||
});
|
||||
|
||||
it('passes etag from last successful fetch', async () => {
|
||||
const lastSuccessfulFetchResponse = { eTag: 'etag' } as FetchResponse;
|
||||
storage.getLastSuccessfulFetchResponse = sinon
|
||||
.stub()
|
||||
.returns(lastSuccessfulFetchResponse);
|
||||
|
||||
await cachingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(backingClient.fetch).to.have.been.calledWith(
|
||||
Object.assign({}, DEFAULT_REQUEST, {
|
||||
eTag: lastSuccessfulFetchResponse.eTag
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('caches timestamp and response if status is 200', async () => {
|
||||
const response = {
|
||||
status: 200,
|
||||
eTag: 'etag',
|
||||
config: { color: 'clear' }
|
||||
};
|
||||
backingClient.fetch = sinon.stub().returns(Promise.resolve(response));
|
||||
|
||||
await cachingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(
|
||||
storageCache.setLastSuccessfulFetchTimestampMillis
|
||||
).to.have.been.calledWith(3000); // Based on mock timer in beforeEach.
|
||||
expect(storage.setLastSuccessfulFetchResponse).to.have.been.calledWith(
|
||||
response
|
||||
);
|
||||
});
|
||||
|
||||
it('sets timestamp, but not config, if 304', async () => {
|
||||
backingClient.fetch = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve({ status: 304 }));
|
||||
|
||||
await cachingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(
|
||||
storageCache.setLastSuccessfulFetchTimestampMillis
|
||||
).to.have.been.calledWith(3000); // Based on mock timer in beforeEach.
|
||||
expect(storage.setLastSuccessfulFetchResponse).not.to.have.been.called;
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 {
|
||||
calculateBackoffMillis,
|
||||
MAX_VALUE_MILLIS,
|
||||
RANDOM_FACTOR
|
||||
} from '../../src/client/exponential_backoff';
|
||||
|
||||
describe('ExponentialBackoff', () => {
|
||||
// Based on
|
||||
// https://github.com/google/closure-library/blob/master/closure/goog/math/exponentialbackoff_test.js#L102
|
||||
describe('backoff', () => {
|
||||
it('increments count and calculates backoff millis', () => {
|
||||
function getMinBackoff(baseValue: number): number {
|
||||
return Math.round(baseValue - baseValue * RANDOM_FACTOR);
|
||||
}
|
||||
|
||||
function getMaxBackoff(baseValue: number): number {
|
||||
return Math.round(baseValue + baseValue * RANDOM_FACTOR);
|
||||
}
|
||||
|
||||
// Deviation from Closure: starts from zero exponent.
|
||||
expect(calculateBackoffMillis(0)).to.be.within(
|
||||
getMinBackoff(1000),
|
||||
getMaxBackoff(1000)
|
||||
);
|
||||
|
||||
expect(calculateBackoffMillis(1)).to.be.within(
|
||||
getMinBackoff(2000),
|
||||
getMaxBackoff(2000)
|
||||
);
|
||||
|
||||
expect(calculateBackoffMillis(2)).to.be.within(
|
||||
getMinBackoff(4000),
|
||||
getMaxBackoff(4000)
|
||||
);
|
||||
|
||||
expect(calculateBackoffMillis(4)).to.be.within(
|
||||
getMinBackoff(8000),
|
||||
MAX_VALUE_MILLIS
|
||||
);
|
||||
|
||||
expect(calculateBackoffMillis(5)).to.be.within(
|
||||
getMinBackoff(16000),
|
||||
MAX_VALUE_MILLIS
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 '../setup';
|
||||
import { expect } from 'chai';
|
||||
import { RestClient } from '../../src/client/rest_client';
|
||||
import { FirebaseInstallations } from '@firebase/installations-types';
|
||||
import * as sinon from 'sinon';
|
||||
import { ERROR_FACTORY, ErrorCode } from '../../src/errors';
|
||||
import { FirebaseError } from '@firebase/util';
|
||||
import {
|
||||
FetchRequest,
|
||||
RemoteConfigAbortSignal
|
||||
} from '../../src/client/remote_config_fetch_client';
|
||||
|
||||
const DEFAULT_REQUEST: FetchRequest = {
|
||||
cacheMaxAgeMillis: 1,
|
||||
signal: new RemoteConfigAbortSignal()
|
||||
};
|
||||
|
||||
describe('RestClient', () => {
|
||||
const firebaseInstallations = {} as FirebaseInstallations;
|
||||
let client: RestClient;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new RestClient(
|
||||
firebaseInstallations,
|
||||
'sdk-version',
|
||||
'namespace',
|
||||
'project-id',
|
||||
'api-key',
|
||||
'app-id'
|
||||
);
|
||||
firebaseInstallations.getId = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve('fis-id'));
|
||||
firebaseInstallations.getToken = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve('fis-token'));
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
let fetchStub: sinon.SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
fetchStub = sinon
|
||||
.stub(window, 'fetch')
|
||||
.returns(Promise.resolve(new Response('{}')));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchStub.restore();
|
||||
});
|
||||
|
||||
it('handles 200/UPDATE responses', async () => {
|
||||
const expectedResponse = {
|
||||
status: 200,
|
||||
eTag: 'etag',
|
||||
state: 'UPDATE',
|
||||
entries: { color: 'sparkling' }
|
||||
};
|
||||
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
ok: true,
|
||||
status: expectedResponse.status,
|
||||
headers: new Headers({ ETag: expectedResponse.eTag }),
|
||||
json: () =>
|
||||
Promise.resolve({
|
||||
entries: expectedResponse.entries,
|
||||
state: expectedResponse.state
|
||||
})
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const response = await client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(response).to.deep.eq({
|
||||
status: expectedResponse.status,
|
||||
eTag: expectedResponse.eTag,
|
||||
config: expectedResponse.entries
|
||||
});
|
||||
});
|
||||
|
||||
it('calls the correct endpoint', async () => {
|
||||
await client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(fetchStub).to.be.calledWith(
|
||||
'https://firebaseremoteconfig.googleapis.com/v1/projects/project-id/namespaces/namespace:fetch?key=api-key',
|
||||
sinon.match.object
|
||||
);
|
||||
});
|
||||
|
||||
it('passes injected params', async () => {
|
||||
await client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(fetchStub).to.be.calledWith(
|
||||
sinon.match.string,
|
||||
sinon.match({
|
||||
body:
|
||||
'{"sdk_version":"sdk-version","app_instance_id":"fis-id","app_instance_id_token":"fis-token","app_id":"app-id","language_code":"en-US"}'
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('throws on network failure', async () => {
|
||||
// The Fetch API throws a TypeError on network falure:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/fetch#Exceptions
|
||||
const originalError = new TypeError('Network request failed');
|
||||
fetchStub.returns(Promise.reject(originalError));
|
||||
|
||||
const fetchPromise = client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
const firebaseError = ERROR_FACTORY.create(ErrorCode.FETCH_NETWORK, {
|
||||
originalErrorMessage: originalError.message
|
||||
});
|
||||
|
||||
await expect(fetchPromise)
|
||||
.to.eventually.be.rejectedWith(FirebaseError, firebaseError.message)
|
||||
.with.property('originalErrorMessage', 'Network request failed');
|
||||
});
|
||||
|
||||
it('throws on JSON parse failure', async () => {
|
||||
// JSON parsing throws a SyntaxError on failure:
|
||||
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/JSON/parse#Exceptions
|
||||
const res = new Response(/* empty body */);
|
||||
sinon
|
||||
.stub(res, 'json')
|
||||
.throws(new SyntaxError('Unexpected end of input'));
|
||||
fetchStub.returns(Promise.resolve(res));
|
||||
|
||||
const fetchPromise = client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
const firebaseError = ERROR_FACTORY.create(ErrorCode.FETCH_PARSE, {
|
||||
originalErrorMessage: 'Unexpected end of input'
|
||||
});
|
||||
|
||||
await expect(fetchPromise)
|
||||
.to.eventually.be.rejectedWith(FirebaseError, firebaseError.message)
|
||||
.with.property('originalErrorMessage', 'Unexpected end of input');
|
||||
});
|
||||
|
||||
it('handles 304 status code and empty body', async () => {
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
status: 304,
|
||||
headers: new Headers({ ETag: 'response-etag' })
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const response = await client.fetch(
|
||||
Object.assign({}, DEFAULT_REQUEST, {
|
||||
eTag: 'request-etag'
|
||||
})
|
||||
);
|
||||
|
||||
expect(fetchStub).to.be.calledWith(
|
||||
sinon.match.string,
|
||||
sinon.match({ headers: { 'If-None-Match': 'request-etag' } })
|
||||
);
|
||||
|
||||
expect(response).to.deep.eq({
|
||||
status: 304,
|
||||
eTag: 'response-etag',
|
||||
config: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes INSTANCE_STATE_UNSPECIFIED state to server error', async () => {
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers({ ETag: 'etag' }),
|
||||
json: () => Promise.resolve({ state: 'INSTANCE_STATE_UNSPECIFIED' })
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const fetchPromise = client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
const error = ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus: 500
|
||||
});
|
||||
|
||||
await expect(fetchPromise)
|
||||
.to.eventually.be.rejectedWith(FirebaseError, error.message)
|
||||
.with.property('httpStatus', 500);
|
||||
});
|
||||
|
||||
it('normalizes NO_CHANGE state to 304 status', async () => {
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers({ ETag: 'etag' }),
|
||||
json: () => Promise.resolve({ state: 'NO_CHANGE' })
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const response = await client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
expect(response).to.deep.eq({
|
||||
status: 304,
|
||||
eTag: 'etag',
|
||||
config: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes empty change states', async () => {
|
||||
for (const state of ['NO_TEMPLATE', 'EMPTY_CONFIG']) {
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
status: 200,
|
||||
headers: new Headers({ ETag: 'etag' }),
|
||||
json: () => Promise.resolve({ state })
|
||||
} as Response)
|
||||
);
|
||||
|
||||
await expect(client.fetch(DEFAULT_REQUEST)).to.eventually.be.deep.eq({
|
||||
status: 200,
|
||||
eTag: 'etag',
|
||||
config: {}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
it('throws error on HTTP error status', async () => {
|
||||
// Error codes from logs plus an arbitrary unexpected code (300)
|
||||
for (const status of [300, 400, 403, 404, 415, 429, 500, 503, 504]) {
|
||||
fetchStub.returns(
|
||||
Promise.resolve({
|
||||
status,
|
||||
headers: new Headers()
|
||||
} as Response)
|
||||
);
|
||||
|
||||
const fetchPromise = client.fetch(DEFAULT_REQUEST);
|
||||
|
||||
const error = ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus: status
|
||||
});
|
||||
|
||||
await expect(fetchPromise)
|
||||
.to.eventually.be.rejectedWith(FirebaseError, error.message)
|
||||
.with.property('httpStatus', status);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,218 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 * as sinon from 'sinon';
|
||||
import { Storage, ThrottleMetadata } from '../../src/storage/storage';
|
||||
import {
|
||||
RemoteConfigFetchClient,
|
||||
FetchRequest,
|
||||
FetchResponse,
|
||||
RemoteConfigAbortSignal
|
||||
} from '../../src/client/remote_config_fetch_client';
|
||||
import {
|
||||
setAbortableTimeout,
|
||||
RetryingClient
|
||||
} from '../../src/client/retrying_client';
|
||||
import { ErrorCode, ERROR_FACTORY } from '../../src/errors';
|
||||
import '../setup';
|
||||
|
||||
const DEFAULT_REQUEST: FetchRequest = {
|
||||
cacheMaxAgeMillis: 1,
|
||||
signal: new RemoteConfigAbortSignal()
|
||||
};
|
||||
|
||||
describe('RetryingClient', () => {
|
||||
let backingClient: RemoteConfigFetchClient;
|
||||
let storage: Storage;
|
||||
let retryingClient: RetryingClient;
|
||||
let abortSignal: RemoteConfigAbortSignal;
|
||||
|
||||
beforeEach(() => {
|
||||
backingClient = {} as RemoteConfigFetchClient;
|
||||
storage = {} as Storage;
|
||||
retryingClient = new RetryingClient(backingClient, storage);
|
||||
storage.getThrottleMetadata = sinon.stub().returns(Promise.resolve());
|
||||
storage.deleteThrottleMetadata = sinon.stub().returns(Promise.resolve());
|
||||
storage.setThrottleMetadata = sinon.stub().returns(Promise.resolve());
|
||||
backingClient.fetch = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve({ status: 200 }));
|
||||
abortSignal = new RemoteConfigAbortSignal();
|
||||
});
|
||||
|
||||
describe('setAbortableTimeout', () => {
|
||||
let clock: sinon.SinonFakeTimers;
|
||||
|
||||
beforeEach(() => {
|
||||
// Sets Date.now() to zero.
|
||||
clock = sinon.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clock.restore();
|
||||
});
|
||||
|
||||
it('Derives backoff from end time', async () => {
|
||||
const setTimeoutSpy = sinon.spy(window, 'setTimeout');
|
||||
|
||||
const timeoutPromise = setAbortableTimeout(abortSignal, Date.now() + 1);
|
||||
|
||||
// Advances mocked clock so setTimeout logic runs.
|
||||
clock.runAll();
|
||||
|
||||
await timeoutPromise;
|
||||
|
||||
expect(setTimeoutSpy).to.have.been.calledWith(sinon.match.any, 1);
|
||||
});
|
||||
|
||||
it('Normalizes end time in the past to zero backoff', async () => {
|
||||
const setTimeoutSpy = sinon.spy(window, 'setTimeout');
|
||||
|
||||
const timeoutPromise = setAbortableTimeout(abortSignal, Date.now() - 1);
|
||||
|
||||
// Advances mocked clock so setTimeout logic runs.
|
||||
clock.runAll();
|
||||
|
||||
await timeoutPromise;
|
||||
|
||||
expect(setTimeoutSpy).to.have.been.calledWith(sinon.match.any, 0);
|
||||
|
||||
setTimeoutSpy.restore();
|
||||
});
|
||||
|
||||
it('listens for abort event and rejects promise', async () => {
|
||||
const throttleEndTimeMillis = 1000;
|
||||
|
||||
const timeoutPromise = setAbortableTimeout(
|
||||
abortSignal,
|
||||
throttleEndTimeMillis
|
||||
);
|
||||
|
||||
abortSignal.abort();
|
||||
|
||||
const expectedError = ERROR_FACTORY.create(ErrorCode.FETCH_THROTTLE, {
|
||||
throttleEndTimeMillis
|
||||
});
|
||||
|
||||
await expect(timeoutPromise).to.eventually.be.rejectedWith(
|
||||
expectedError.message
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
it('returns success response', async () => {
|
||||
const setTimeoutSpy = sinon.spy(window, 'setTimeout');
|
||||
|
||||
const expectedResponse: FetchResponse = {
|
||||
status: 200,
|
||||
eTag: 'etag',
|
||||
config: {}
|
||||
};
|
||||
backingClient.fetch = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve(expectedResponse));
|
||||
|
||||
const actualResponse = retryingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
await expect(actualResponse).to.eventually.deep.eq(expectedResponse);
|
||||
|
||||
// Asserts setTimeout is passed a zero delay, since throttleEndTimeMillis is set to Date.now,
|
||||
// which is faked to be a constant.
|
||||
expect(setTimeoutSpy).to.have.been.calledWith(sinon.match.any, 0);
|
||||
|
||||
expect(storage.deleteThrottleMetadata).to.have.been.called;
|
||||
|
||||
setTimeoutSpy.restore();
|
||||
});
|
||||
|
||||
it('rethrows unretriable errors rather than retrying', async () => {
|
||||
const expectedError = ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus: 400
|
||||
});
|
||||
backingClient.fetch = sinon.stub().returns(Promise.reject(expectedError));
|
||||
|
||||
const fetchPromise = retryingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
await expect(fetchPromise).to.eventually.be.rejectedWith(expectedError);
|
||||
});
|
||||
|
||||
it('retries on retriable errors', async () => {
|
||||
// Configures Date.now() to advance clock from zero in 20ms increments, enabling
|
||||
// tests to assert a known throttle end time and allow setTimeout to work.
|
||||
const clock = sinon.useFakeTimers({ shouldAdvanceTime: true });
|
||||
|
||||
// Ensures backoff is always zero, which simplifies reasoning about timer.
|
||||
const powSpy = sinon.stub(Math, 'pow').returns(0);
|
||||
const randomSpy = sinon.stub(Math, 'random').returns(0.5);
|
||||
|
||||
// Simulates a service call that returns errors several times before returning success.
|
||||
// Error codes from logs.
|
||||
const errorResponseStatuses = [429, 500, 503, 504];
|
||||
const errorResponseCount = errorResponseStatuses.length;
|
||||
|
||||
backingClient.fetch = sinon.stub().callsFake(() => {
|
||||
const httpStatus = errorResponseStatuses.pop();
|
||||
|
||||
if (httpStatus) {
|
||||
// Triggers retry by returning a retriable status code.
|
||||
const expectedError = ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus
|
||||
});
|
||||
return Promise.reject(expectedError);
|
||||
}
|
||||
|
||||
// Halts retrying by returning success.
|
||||
// Note backoff never terminates if the server always errors.
|
||||
return Promise.resolve({ status: 200 });
|
||||
});
|
||||
|
||||
await retryingClient.fetch(DEFAULT_REQUEST);
|
||||
|
||||
// Asserts throttle metadata was persisted after each error response.
|
||||
for (let i = 1; i <= errorResponseCount; i++) {
|
||||
expect(storage.setThrottleMetadata).to.have.been.calledWith({
|
||||
backoffCount: i,
|
||||
throttleEndTimeMillis: i * 20
|
||||
});
|
||||
}
|
||||
|
||||
powSpy.restore();
|
||||
randomSpy.restore();
|
||||
clock.restore();
|
||||
});
|
||||
});
|
||||
|
||||
describe('attemptFetch', () => {
|
||||
it('honors metadata when initializing', async () => {
|
||||
const clock = sinon.useFakeTimers({ shouldAdvanceTime: true });
|
||||
const setTimeoutSpy = sinon.spy(window, 'setTimeout');
|
||||
|
||||
const throttleMetadata = {
|
||||
throttleEndTimeMillis: 123
|
||||
} as ThrottleMetadata;
|
||||
|
||||
await retryingClient.attemptFetch(DEFAULT_REQUEST, throttleMetadata);
|
||||
|
||||
expect(setTimeoutSpy).to.have.been.calledWith(sinon.match.any, 123);
|
||||
|
||||
clock.restore();
|
||||
setTimeoutSpy.restore();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { hasErrorCode, ERROR_FACTORY, ErrorCode } from '../src/errors';
|
||||
import './setup';
|
||||
|
||||
describe('hasErrorCode', () => {
|
||||
it('defaults false', () => {
|
||||
const error = new Error();
|
||||
expect(hasErrorCode(error, ErrorCode.REGISTRATION_PROJECT_ID)).to.be.false;
|
||||
});
|
||||
it('returns true for FirebaseError with given code', () => {
|
||||
const error = ERROR_FACTORY.create(ErrorCode.REGISTRATION_PROJECT_ID);
|
||||
expect(hasErrorCode(error, ErrorCode.REGISTRATION_PROJECT_ID)).to.be.true;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { getUserLanguage } from '../src/language';
|
||||
import './setup';
|
||||
|
||||
// Adapts getUserLanguage tests from packages/auth/test/utils_test.js for TypeScript.
|
||||
describe('getUserLanguage', () => {
|
||||
it('prioritizes navigator.languages', () => {
|
||||
expect(
|
||||
getUserLanguage({
|
||||
languages: ['de', 'en'],
|
||||
language: 'en'
|
||||
})
|
||||
).to.eq('de');
|
||||
});
|
||||
|
||||
it('falls back to navigator.language', () => {
|
||||
expect(
|
||||
getUserLanguage({
|
||||
language: 'en'
|
||||
} as NavigatorLanguage)
|
||||
).to.eq('en');
|
||||
});
|
||||
|
||||
it('defaults undefined', () => {
|
||||
expect(getUserLanguage({} as NavigatorLanguage)).to.be.undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,501 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { FirebaseApp } from '@firebase/app-types';
|
||||
import {
|
||||
RemoteConfig as RemoteConfigType,
|
||||
LogLevel as RemoteConfigLogLevel
|
||||
} from '@firebase/remote-config-types';
|
||||
import { expect } from 'chai';
|
||||
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 { Value } from '../src/value';
|
||||
import './setup';
|
||||
import { ERROR_FACTORY, ErrorCode } from '../src/errors';
|
||||
import { Logger, LogLevel as FirebaseLogLevel } from '@firebase/logger';
|
||||
|
||||
describe('RemoteConfig', () => {
|
||||
const ACTIVE_CONFIG = {
|
||||
key1: 'active_config_value_1',
|
||||
key2: 'active_config_value_2',
|
||||
key3: 'true',
|
||||
key4: '123'
|
||||
};
|
||||
const DEFAULT_CONFIG = {
|
||||
key1: 'default_config_value_1',
|
||||
key2: 'default_config_value_2',
|
||||
key3: 'false',
|
||||
key4: '345',
|
||||
test: 'test'
|
||||
};
|
||||
|
||||
let app: FirebaseApp;
|
||||
let client: RemoteConfigFetchClient;
|
||||
let storageCache: StorageCache;
|
||||
let storage: Storage;
|
||||
let logger: Logger;
|
||||
let rc: RemoteConfigType;
|
||||
|
||||
let getActiveConfigStub: sinon.SinonStub;
|
||||
let loggerDebugSpy: sinon.SinonSpy;
|
||||
let loggerLogLevelSpy: sinon.SinonSpy;
|
||||
|
||||
beforeEach(() => {
|
||||
// Clears stubbed behavior between each test.
|
||||
app = {} as FirebaseApp;
|
||||
client = {} as RemoteConfigFetchClient;
|
||||
storageCache = {} as StorageCache;
|
||||
storage = {} as Storage;
|
||||
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);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
loggerDebugSpy.restore();
|
||||
loggerLogLevelSpy.restore();
|
||||
});
|
||||
|
||||
// Adapts getUserLanguage tests from packages/auth/test/utils_test.js for TypeScript.
|
||||
describe('setLogLevel', () => {
|
||||
it('proxies to the FirebaseLogger instance', () => {
|
||||
rc.setLogLevel('debug');
|
||||
|
||||
// Casts spy to any because property setters aren't defined on the SinonSpy type.
|
||||
expect((loggerLogLevelSpy as any).set).to.have.been.calledWith(
|
||||
FirebaseLogLevel.DEBUG
|
||||
);
|
||||
});
|
||||
|
||||
it('normalizes levels other than DEBUG and SILENT to ERROR', () => {
|
||||
for (const logLevel of ['info', 'verbose', 'error', 'severe']) {
|
||||
rc.setLogLevel(logLevel as RemoteConfigLogLevel);
|
||||
|
||||
// Casts spy to any because property setters aren't defined on the SinonSpy type.
|
||||
expect((loggerLogLevelSpy as any).set).to.have.been.calledWith(
|
||||
FirebaseLogLevel.ERROR
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('ensureInitialized', () => {
|
||||
it('warms cache', async () => {
|
||||
storageCache.loadFromStorage = sinon.stub().returns(Promise.resolve());
|
||||
|
||||
await rc.ensureInitialized();
|
||||
|
||||
expect(storageCache.loadFromStorage).to.have.been.calledOnce;
|
||||
});
|
||||
|
||||
it('de-duplicates repeated calls', async () => {
|
||||
storageCache.loadFromStorage = sinon.stub().returns(Promise.resolve());
|
||||
|
||||
await rc.ensureInitialized();
|
||||
await rc.ensureInitialized();
|
||||
|
||||
expect(storageCache.loadFromStorage).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchTimeMillis', () => {
|
||||
it('normalizes undefined values', async () => {
|
||||
storageCache.getLastSuccessfulFetchTimestampMillis = sinon
|
||||
.stub()
|
||||
.returns(undefined);
|
||||
|
||||
expect(rc.fetchTimeMillis).to.eq(-1);
|
||||
});
|
||||
|
||||
it('reads from cache', async () => {
|
||||
const lastFetchTimeMillis = 123;
|
||||
|
||||
storageCache.getLastSuccessfulFetchTimestampMillis = sinon
|
||||
.stub()
|
||||
.returns(lastFetchTimeMillis);
|
||||
|
||||
expect(rc.fetchTimeMillis).to.eq(lastFetchTimeMillis);
|
||||
});
|
||||
});
|
||||
|
||||
describe('lastFetchStatus', () => {
|
||||
it('normalizes undefined values', async () => {
|
||||
storageCache.getLastFetchStatus = sinon.stub().returns(undefined);
|
||||
|
||||
expect(rc.lastFetchStatus).to.eq('no-fetch-yet');
|
||||
});
|
||||
|
||||
it('reads from cache', async () => {
|
||||
const lastFetchStatus = 'success';
|
||||
|
||||
storageCache.getLastFetchStatus = sinon.stub().returns(lastFetchStatus);
|
||||
|
||||
expect(rc.lastFetchStatus).to.eq(lastFetchStatus);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getValue', () => {
|
||||
it('returns the active value if available', () => {
|
||||
getActiveConfigStub.returns(ACTIVE_CONFIG);
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getValue('key1')).to.deep.eq(
|
||||
new Value('remote', ACTIVE_CONFIG.key1)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the default value if active is not available', () => {
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getValue('key1')).to.deep.eq(
|
||||
new Value('default', DEFAULT_CONFIG.key1)
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the stringified default boolean values if active is not available', () => {
|
||||
const DEFAULTS = { trueVal: true, falseVal: false };
|
||||
rc.defaultConfig = DEFAULTS;
|
||||
|
||||
expect(rc.getValue('trueVal')).to.deep.eq(
|
||||
new Value('default', String(DEFAULTS.trueVal))
|
||||
);
|
||||
expect(rc.getValue('falseVal')).to.deep.eq(
|
||||
new Value('default', String(DEFAULTS.falseVal))
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the stringified default numeric values if active is not available', () => {
|
||||
const DEFAULTS = { negative: -1, zero: 0, positive: 11 };
|
||||
rc.defaultConfig = DEFAULTS;
|
||||
|
||||
expect(rc.getValue('negative')).to.deep.eq(
|
||||
new Value('default', String(DEFAULTS.negative))
|
||||
);
|
||||
expect(rc.getValue('zero')).to.deep.eq(
|
||||
new Value('default', String(DEFAULTS.zero))
|
||||
);
|
||||
expect(rc.getValue('positive')).to.deep.eq(
|
||||
new Value('default', String(DEFAULTS.positive))
|
||||
);
|
||||
});
|
||||
|
||||
it('returns the static value if active and default are not available', () => {
|
||||
expect(rc.getValue('key1')).to.deep.eq(new Value('static'));
|
||||
|
||||
// Asserts debug message logged if static value is returned, per EAP feedback.
|
||||
expect(logger.debug).to.have.been.called;
|
||||
});
|
||||
|
||||
it('logs if initialization is incomplete', async () => {
|
||||
// Defines default value to isolate initialization logging from static value logging.
|
||||
rc.defaultConfig = { key1: 'val' };
|
||||
|
||||
// Gets value before initialization.
|
||||
rc.getValue('key1');
|
||||
|
||||
// Asserts getValue logs.
|
||||
expect(logger.debug).to.have.been.called;
|
||||
|
||||
// Enables initialization to complete.
|
||||
storageCache.loadFromStorage = sinon.stub().returns(Promise.resolve());
|
||||
|
||||
// Ensures initialization completes.
|
||||
await rc.ensureInitialized();
|
||||
|
||||
// Gets value after initialization.
|
||||
rc.getValue('key1');
|
||||
|
||||
// Asserts getValue doesn't log after initialization is complete.
|
||||
expect(logger.debug).to.have.been.calledOnce;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBoolean', () => {
|
||||
it('returns the active value if available', () => {
|
||||
getActiveConfigStub.returns(ACTIVE_CONFIG);
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getBoolean('key3')).to.be.true;
|
||||
});
|
||||
|
||||
it('returns the default value if active is not available', () => {
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getBoolean('key3')).to.be.false;
|
||||
});
|
||||
|
||||
it('returns the static value if active and default are not available', () => {
|
||||
expect(rc.getBoolean('key3')).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('getString', () => {
|
||||
it('returns the active value if available', () => {
|
||||
getActiveConfigStub.returns(ACTIVE_CONFIG);
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getString('key1')).to.eq(ACTIVE_CONFIG.key1);
|
||||
});
|
||||
|
||||
it('returns the default value if active is not available', () => {
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getString('key2')).to.eq(DEFAULT_CONFIG.key2);
|
||||
});
|
||||
|
||||
it('returns the static value if active and default are not available', () => {
|
||||
expect(rc.getString('key1')).to.eq('');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNumber', () => {
|
||||
it('returns the active value if available', () => {
|
||||
getActiveConfigStub.returns(ACTIVE_CONFIG);
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getNumber('key4')).to.eq(Number(ACTIVE_CONFIG.key4));
|
||||
});
|
||||
|
||||
it('returns the default value if active is not available', () => {
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getNumber('key4')).to.eq(Number(DEFAULT_CONFIG.key4));
|
||||
});
|
||||
|
||||
it('returns the static value if active and default are not available', () => {
|
||||
expect(rc.getNumber('key1')).to.eq(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAll', () => {
|
||||
it('returns values for all keys included in active and default configs', () => {
|
||||
getActiveConfigStub.returns(ACTIVE_CONFIG);
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getAll()).to.deep.eq({
|
||||
key1: new Value('remote', ACTIVE_CONFIG.key1),
|
||||
key2: new Value('remote', ACTIVE_CONFIG.key2),
|
||||
key3: new Value('remote', ACTIVE_CONFIG.key3),
|
||||
key4: new Value('remote', ACTIVE_CONFIG.key4),
|
||||
test: new Value('default', DEFAULT_CONFIG.test)
|
||||
});
|
||||
});
|
||||
|
||||
it('returns values in default if active is not available', () => {
|
||||
rc.defaultConfig = DEFAULT_CONFIG;
|
||||
|
||||
expect(rc.getAll()).to.deep.eq({
|
||||
key1: new Value('default', DEFAULT_CONFIG.key1),
|
||||
key2: new Value('default', DEFAULT_CONFIG.key2),
|
||||
key3: new Value('default', DEFAULT_CONFIG.key3),
|
||||
key4: new Value('default', DEFAULT_CONFIG.key4),
|
||||
test: new Value('default', DEFAULT_CONFIG.test)
|
||||
});
|
||||
});
|
||||
|
||||
it('returns empty object if both active and default configs are not defined', () => {
|
||||
expect(rc.getAll()).to.deep.eq({});
|
||||
});
|
||||
});
|
||||
|
||||
describe('activate', () => {
|
||||
const ETAG = 'etag';
|
||||
const CONFIG = { key: 'val' };
|
||||
const NEW_ETAG = 'new_etag';
|
||||
|
||||
let getLastSuccessfulFetchResponseStub: sinon.SinonStub;
|
||||
let getActiveConfigEtagStub: sinon.SinonStub;
|
||||
let setActiveConfigEtagStub: sinon.SinonStub;
|
||||
let setActiveConfigStub: sinon.SinonStub;
|
||||
|
||||
beforeEach(() => {
|
||||
getLastSuccessfulFetchResponseStub = sinon.stub();
|
||||
getActiveConfigEtagStub = sinon.stub();
|
||||
setActiveConfigEtagStub = sinon.stub();
|
||||
setActiveConfigStub = sinon.stub();
|
||||
|
||||
storage.getLastSuccessfulFetchResponse = getLastSuccessfulFetchResponseStub;
|
||||
storage.getActiveConfigEtag = getActiveConfigEtagStub;
|
||||
storage.setActiveConfigEtag = setActiveConfigEtagStub;
|
||||
storageCache.setActiveConfig = setActiveConfigStub;
|
||||
});
|
||||
|
||||
it('does not activate if last successful fetch response is undefined', async () => {
|
||||
getLastSuccessfulFetchResponseStub.returns(Promise.resolve());
|
||||
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
|
||||
|
||||
const activateResponse = await rc.activate();
|
||||
|
||||
expect(activateResponse).to.be.false;
|
||||
expect(storage.setActiveConfigEtag).to.not.have.been.called;
|
||||
expect(storageCache.setActiveConfig).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('does not activate if fetched and active etags are the same', async () => {
|
||||
getLastSuccessfulFetchResponseStub.returns(
|
||||
Promise.resolve({ config: {}, etag: ETAG })
|
||||
);
|
||||
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
|
||||
|
||||
const activateResponse = await rc.activate();
|
||||
|
||||
expect(activateResponse).to.be.false;
|
||||
expect(storage.setActiveConfigEtag).to.not.have.been.called;
|
||||
expect(storageCache.setActiveConfig).to.not.have.been.called;
|
||||
});
|
||||
|
||||
it('activates if fetched and active etags are different', async () => {
|
||||
getLastSuccessfulFetchResponseStub.returns(
|
||||
Promise.resolve({ config: CONFIG, eTag: NEW_ETAG })
|
||||
);
|
||||
getActiveConfigEtagStub.returns(Promise.resolve(ETAG));
|
||||
|
||||
const activateResponse = await rc.activate();
|
||||
|
||||
expect(activateResponse).to.be.true;
|
||||
expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG);
|
||||
expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG);
|
||||
});
|
||||
|
||||
it('activates if fetched is defined but active config is not', async () => {
|
||||
getLastSuccessfulFetchResponseStub.returns(
|
||||
Promise.resolve({ config: CONFIG, eTag: NEW_ETAG })
|
||||
);
|
||||
getActiveConfigEtagStub.returns(Promise.resolve());
|
||||
|
||||
const activateResponse = await rc.activate();
|
||||
|
||||
expect(activateResponse).to.be.true;
|
||||
expect(storage.setActiveConfigEtag).to.have.been.calledWith(NEW_ETAG);
|
||||
expect(storageCache.setActiveConfig).to.have.been.calledWith(CONFIG);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchAndActivate', () => {
|
||||
let rcActivateStub: sinon.SinonStub<[], Promise<boolean>>;
|
||||
|
||||
beforeEach(() => {
|
||||
sinon.stub(rc, 'fetch').returns(Promise.resolve());
|
||||
rcActivateStub = sinon.stub(rc, 'activate');
|
||||
});
|
||||
it('calls fetch and activate and returns activation boolean if true', async () => {
|
||||
rcActivateStub.returns(Promise.resolve(true));
|
||||
|
||||
const response = await rc.fetchAndActivate();
|
||||
|
||||
expect(response).to.be.true;
|
||||
expect(rc.fetch).to.have.been.called;
|
||||
expect(rc.activate).to.have.been.called;
|
||||
});
|
||||
|
||||
it('calls fetch and activate and returns activation boolean if false', async () => {
|
||||
rcActivateStub.returns(Promise.resolve(false));
|
||||
|
||||
const response = await rc.fetchAndActivate();
|
||||
|
||||
expect(response).to.be.false;
|
||||
expect(rc.fetch).to.have.been.called;
|
||||
expect(rc.activate).to.have.been.called;
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetch', () => {
|
||||
let timeoutStub: sinon.SinonStub;
|
||||
beforeEach(() => {
|
||||
client.fetch = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve({ status: 200 } as FetchResponse));
|
||||
storageCache.setLastFetchStatus = sinon.stub();
|
||||
timeoutStub = sinon.stub(window, 'setTimeout');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
timeoutStub.restore();
|
||||
});
|
||||
|
||||
it('defines a default timeout', async () => {
|
||||
await rc.fetch();
|
||||
|
||||
expect(timeoutStub).to.have.been.calledWith(sinon.match.any, 60000);
|
||||
});
|
||||
|
||||
it('honors a custom timeout', async () => {
|
||||
rc.settings.fetchTimeoutMillis = 1000;
|
||||
|
||||
await rc.fetch();
|
||||
|
||||
expect(timeoutStub).to.have.been.calledWith(sinon.match.any, 1000);
|
||||
});
|
||||
|
||||
it('sets success status', async () => {
|
||||
for (const status of [200, 304]) {
|
||||
client.fetch = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve({ status } as FetchResponse));
|
||||
|
||||
await rc.fetch();
|
||||
|
||||
expect(storageCache.setLastFetchStatus).to.have.been.calledWith(
|
||||
'success'
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it('sets throttle status', async () => {
|
||||
storage.getThrottleMetadata = sinon.stub().returns(Promise.resolve({}));
|
||||
|
||||
const error = ERROR_FACTORY.create(ErrorCode.FETCH_THROTTLE, {
|
||||
throttleEndTimeMillis: 123
|
||||
});
|
||||
|
||||
client.fetch = sinon.stub().returns(Promise.reject(error));
|
||||
|
||||
const fetchPromise = rc.fetch();
|
||||
|
||||
await expect(fetchPromise).to.eventually.be.rejectedWith(error);
|
||||
expect(storageCache.setLastFetchStatus).to.have.been.calledWith(
|
||||
'throttle'
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults to failure status', async () => {
|
||||
storage.getThrottleMetadata = sinon.stub().returns(Promise.resolve());
|
||||
|
||||
const error = ERROR_FACTORY.create(ErrorCode.FETCH_STATUS, {
|
||||
httpStatus: 400
|
||||
});
|
||||
|
||||
client.fetch = sinon.stub().returns(Promise.reject(error));
|
||||
|
||||
const fetchPromise = rc.fetch();
|
||||
|
||||
await expect(fetchPromise).to.eventually.be.rejectedWith(error);
|
||||
expect(storageCache.setLastFetchStatus).to.have.been.calledWith(
|
||||
'failure'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 { use } from 'chai';
|
||||
import * as sinonChai from 'sinon-chai';
|
||||
import * as chaiAsPromised from 'chai-as-promised';
|
||||
|
||||
// Normalizes Sinon assertions to Chai syntax.
|
||||
use(sinonChai);
|
||||
|
||||
// Adds Promise-friendly syntax to Chai.
|
||||
use(chaiAsPromised);
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 '../setup';
|
||||
import { expect } from 'chai';
|
||||
import {
|
||||
Storage,
|
||||
ThrottleMetadata,
|
||||
openDatabase,
|
||||
APP_NAMESPACE_STORE
|
||||
} from '../../src/storage/storage';
|
||||
import { FetchResponse } from '../../src/client/remote_config_fetch_client';
|
||||
|
||||
// Clears global IndexedDB state.
|
||||
async function clearDatabase(): Promise<void> {
|
||||
const db = await openDatabase();
|
||||
db.transaction([APP_NAMESPACE_STORE], 'readwrite')
|
||||
.objectStore(APP_NAMESPACE_STORE)
|
||||
.clear();
|
||||
}
|
||||
|
||||
describe('Storage', () => {
|
||||
const storage = new Storage('appId', 'appName', 'namespace');
|
||||
|
||||
beforeEach(async () => {
|
||||
await clearDatabase();
|
||||
});
|
||||
|
||||
it('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'
|
||||
);
|
||||
});
|
||||
|
||||
it('sets and gets last fetch attempt status', async () => {
|
||||
const expectedStatus = 'success';
|
||||
|
||||
await storage.setLastFetchStatus(expectedStatus);
|
||||
|
||||
const actualStatus = await storage.getLastFetchStatus();
|
||||
|
||||
expect(actualStatus).to.deep.eq(expectedStatus);
|
||||
});
|
||||
|
||||
it('sets and gets last fetch success timestamp', async () => {
|
||||
const lastSuccessfulFetchTimestampMillis = 123;
|
||||
|
||||
await storage.setLastSuccessfulFetchTimestampMillis(
|
||||
lastSuccessfulFetchTimestampMillis
|
||||
);
|
||||
|
||||
const actualMetadata = await storage.getLastSuccessfulFetchTimestampMillis();
|
||||
|
||||
expect(actualMetadata).to.deep.eq(lastSuccessfulFetchTimestampMillis);
|
||||
});
|
||||
|
||||
it('sets and gets last successful fetch response', async () => {
|
||||
const lastSuccessfulFetchResponse = { status: 200 } as FetchResponse;
|
||||
|
||||
await storage.setLastSuccessfulFetchResponse(lastSuccessfulFetchResponse);
|
||||
|
||||
const actualConfig = await storage.getLastSuccessfulFetchResponse();
|
||||
|
||||
expect(actualConfig).to.deep.eq(lastSuccessfulFetchResponse);
|
||||
});
|
||||
|
||||
it('sets and gets active config', async () => {
|
||||
const expectedConfig = { key: 'value' };
|
||||
|
||||
await storage.setActiveConfig(expectedConfig);
|
||||
|
||||
const storedConfig = await storage.getActiveConfig();
|
||||
|
||||
expect(storedConfig).to.deep.eq(expectedConfig);
|
||||
});
|
||||
|
||||
it('sets and gets active config etag', async () => {
|
||||
const expectedEtag = 'etag';
|
||||
|
||||
await storage.setActiveConfigEtag(expectedEtag);
|
||||
|
||||
const storedConfigEtag = await storage.getActiveConfigEtag();
|
||||
|
||||
expect(storedConfigEtag).to.deep.eq(expectedEtag);
|
||||
});
|
||||
|
||||
it('sets, gets and deletes throttle metadata', async () => {
|
||||
const expectedMetadata = {
|
||||
throttleEndTimeMillis: 1
|
||||
} as ThrottleMetadata;
|
||||
|
||||
await storage.setThrottleMetadata(expectedMetadata);
|
||||
|
||||
let actualMetadata = await storage.getThrottleMetadata();
|
||||
|
||||
expect(actualMetadata).to.deep.eq(expectedMetadata);
|
||||
|
||||
await storage.deleteThrottleMetadata();
|
||||
|
||||
actualMetadata = await storage.getThrottleMetadata();
|
||||
|
||||
expect(actualMetadata).to.be.undefined;
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 '../setup';
|
||||
import { expect } from 'chai';
|
||||
import * as sinon from 'sinon';
|
||||
import { Storage } from '../../src/storage/storage';
|
||||
import { StorageCache } from '../../src/storage/storage_cache';
|
||||
|
||||
describe('StorageCache', () => {
|
||||
const storage = {} as Storage;
|
||||
let storageCache: StorageCache;
|
||||
|
||||
beforeEach(() => {
|
||||
storageCache = new StorageCache(storage);
|
||||
});
|
||||
|
||||
/**
|
||||
* Read-ahead getter.
|
||||
*/
|
||||
describe('loadFromStorage', () => {
|
||||
it('populates memory cache with persisted data', async () => {
|
||||
const status = 'success';
|
||||
const lastSuccessfulFetchTimestampMillis = 123;
|
||||
const activeConfig = { key: 'value' };
|
||||
|
||||
storage.getLastFetchStatus = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve(status));
|
||||
storage.getLastSuccessfulFetchTimestampMillis = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve(lastSuccessfulFetchTimestampMillis));
|
||||
storage.getActiveConfig = sinon
|
||||
.stub()
|
||||
.returns(Promise.resolve(activeConfig));
|
||||
|
||||
await storageCache.loadFromStorage();
|
||||
|
||||
expect(storage.getLastFetchStatus).to.have.been.called;
|
||||
expect(storage.getLastSuccessfulFetchTimestampMillis).to.have.been.called;
|
||||
expect(storage.getActiveConfig).to.have.been.called;
|
||||
|
||||
expect(storageCache.getLastFetchStatus()).to.eq(status);
|
||||
expect(storageCache.getLastSuccessfulFetchTimestampMillis()).to.deep.eq(
|
||||
lastSuccessfulFetchTimestampMillis
|
||||
);
|
||||
expect(storageCache.getActiveConfig()).to.deep.eq(activeConfig);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setActiveConfig', () => {
|
||||
const activeConfig = { key: 'value2' };
|
||||
|
||||
beforeEach(() => {
|
||||
storage.setActiveConfig = sinon.stub().returns(Promise.resolve());
|
||||
});
|
||||
|
||||
it('writes to memory cache', async () => {
|
||||
await storageCache.setActiveConfig(activeConfig);
|
||||
|
||||
expect(storageCache.getActiveConfig()).to.deep.eq(activeConfig);
|
||||
});
|
||||
|
||||
it('writes to persistent storage', async () => {
|
||||
await storageCache.setActiveConfig(activeConfig);
|
||||
|
||||
expect(storage.setActiveConfig).to.have.been.calledWith(activeConfig);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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 './setup';
|
||||
import { expect } from 'chai';
|
||||
import { Value } from '../src/value';
|
||||
|
||||
describe('Value', () => {
|
||||
describe('asString', () => {
|
||||
it('returns static default string if source is static', () => {
|
||||
expect(new Value('static').asString()).to.eq('');
|
||||
});
|
||||
|
||||
it('returns the value as a string', () => {
|
||||
const VALUE = 'test';
|
||||
const value = new Value('remote', VALUE);
|
||||
|
||||
expect(value.asString()).to.eq(VALUE);
|
||||
});
|
||||
});
|
||||
|
||||
describe('asBoolean', () => {
|
||||
it('returns static default boolean if source is static', () => {
|
||||
expect(new Value('static').asBoolean()).to.be.false;
|
||||
});
|
||||
|
||||
it('returns true for a truthy values', () => {
|
||||
expect(new Value('remote', '1').asBoolean()).to.be.true;
|
||||
expect(new Value('remote', 'true').asBoolean()).to.be.true;
|
||||
expect(new Value('remote', 't').asBoolean()).to.be.true;
|
||||
expect(new Value('remote', 'yes').asBoolean()).to.be.true;
|
||||
expect(new Value('remote', 'y').asBoolean()).to.be.true;
|
||||
expect(new Value('remote', 'on').asBoolean()).to.be.true;
|
||||
});
|
||||
|
||||
it('returns false for non-truthy values', () => {
|
||||
expect(new Value('remote', '').asBoolean()).to.be.false;
|
||||
expect(new Value('remote', 'false').asBoolean()).to.be.false;
|
||||
expect(new Value('remote', 'random string').asBoolean()).to.be.false;
|
||||
});
|
||||
});
|
||||
|
||||
describe('asNumber', () => {
|
||||
it('returns static default number if source is static', () => {
|
||||
expect(new Value('static').asNumber()).to.eq(0);
|
||||
});
|
||||
|
||||
it('returns value as a number', () => {
|
||||
expect(new Value('default', '33').asNumber()).to.eq(33);
|
||||
expect(new Value('default', 'not a number').asNumber()).to.eq(0);
|
||||
expect(new Value('default', '-10').asNumber()).to.eq(-10);
|
||||
expect(new Value('default', '0').asNumber()).to.eq(0);
|
||||
expect(new Value('default', '5.3').asNumber()).to.eq(5.3);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getSource', () => {
|
||||
it('returns the source of the value', () => {
|
||||
expect(new Value('default', 'test').getSource()).to.eq('default');
|
||||
expect(new Value('remote', 'test').getSource()).to.eq('remote');
|
||||
expect(new Value('static').getSource()).to.eq('static');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,154 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<title>Test App</title>
|
||||
<script src="../../firebase/firebase-app.js"></script>
|
||||
<script src="../../firebase/firebase-remote-config.js"></script>
|
||||
<style>
|
||||
textarea {
|
||||
height: 150px;
|
||||
width: 100%;
|
||||
}
|
||||
#rc-defaults {
|
||||
height: 100px;
|
||||
}
|
||||
#rc-settings {
|
||||
height: 60px;
|
||||
}
|
||||
.main-container {
|
||||
font-family: 'Franklin Gothic Medium', 'Arial Narrow', Arial, sans-serif;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
max-width: 1200px;
|
||||
}
|
||||
.flex-container {
|
||||
display: flex;
|
||||
justify-content: space-around;
|
||||
}
|
||||
.column {
|
||||
flex-basis: 0;
|
||||
flex-grow: 1;
|
||||
margin-left: 10px;
|
||||
margin-right: 10px;
|
||||
}
|
||||
.error {
|
||||
color: rgb(179, 29, 29);
|
||||
}
|
||||
.output-box-container {
|
||||
background-color: rgb(220, 220, 220);
|
||||
flex-grow: 1;
|
||||
height: 100%;
|
||||
padding: 10px;
|
||||
word-break: break-word;
|
||||
}
|
||||
.output-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.segment {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.done-checkmark {
|
||||
color: rgb(57, 150, 57);
|
||||
}
|
||||
button {
|
||||
background-color: #039be5;
|
||||
border: 0;
|
||||
color: white;
|
||||
font-size: 14px;
|
||||
height: 30px;
|
||||
margin-right: 4px;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
.buttons-container {
|
||||
border: 1px solid rgb(156, 147, 147);
|
||||
padding: 4px;
|
||||
}
|
||||
.key-container {
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="main-container">
|
||||
<h1>Remote Config Test App</h1>
|
||||
<div class="flex-container">
|
||||
<div class="column">
|
||||
<div class="segment">
|
||||
<div>Firebase config</div>
|
||||
<div><textarea id="firebase-config"></textarea></div>
|
||||
<button onclick="handlerWrapper(initializeFirebase)">
|
||||
Initialize Firebase and Rc
|
||||
</button>
|
||||
</div>
|
||||
<div class="segment">
|
||||
<div>RC defaults</div>
|
||||
<div><textarea id="rc-defaults"></textarea></div>
|
||||
<button onclick="handlerWrapper(setDefaults)">setDefaults</button>
|
||||
</div>
|
||||
<div class="segment">
|
||||
<div>RC settings</div>
|
||||
<div><textarea id="rc-settings"></textarea></div>
|
||||
<button onclick="handlerWrapper(setSettings)">set settings</button>
|
||||
</div>
|
||||
<div class="segment">
|
||||
<div>Log level</div>
|
||||
<div><input type="text" id="log-level-input" /></div>
|
||||
<button onclick="handlerWrapper(setLogLevel)">setLogLevel</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column">
|
||||
<div class="segment">
|
||||
<div>Controls</div>
|
||||
<div class="buttons-container">
|
||||
<button onclick="handlerWrapper(activate)">activate</button>
|
||||
<button onclick="handlerWrapper(ensureInitialized)">
|
||||
ensureInitialized
|
||||
</button>
|
||||
<button onclick="handlerWrapper(rcFetch)">fetch</button>
|
||||
<button onclick="handlerWrapper(fetchAndActivate)">
|
||||
fetchAndActivate
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="segment">
|
||||
<div>Value getters</div>
|
||||
<div class="buttons-container">
|
||||
<div class="key-container">
|
||||
<span>key:</span>
|
||||
<input type="text" id="key-input" />
|
||||
</div>
|
||||
<button onclick="handlerWrapper(getString)">getString</button>
|
||||
<button onclick="handlerWrapper(getBoolean)">getBoolean</button>
|
||||
<button onclick="handlerWrapper(getNumber)">getNumber</button>
|
||||
<button onclick="handlerWrapper(getValue)">getValue</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="segment">
|
||||
<div>General getters</div>
|
||||
<div class="buttons-container">
|
||||
<button onclick="handlerWrapper(getAll)">getAll</button>
|
||||
<button onclick="handlerWrapper(getFetchTimeMillis)">
|
||||
get fetchTimeMillis
|
||||
</button>
|
||||
<button onclick="handlerWrapper(getLastFetchStatus)">
|
||||
get lastFetchStatus
|
||||
</button>
|
||||
<button onclick="handlerWrapper(getSettings)">
|
||||
get settings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="column output-container">
|
||||
<div>Output</div>
|
||||
<div class="output-box-container">
|
||||
<div id="output-box"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script src="./index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,224 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
const FB_CONFIG_PLACEHOLDER = `{
|
||||
apiKey: ...,
|
||||
authDomain: ...,
|
||||
databaseURL: ...,
|
||||
projectId: ...,
|
||||
storageBucket: ...,
|
||||
messagingSenderId: ...,
|
||||
appId: ...
|
||||
}`;
|
||||
const DEFAULTS_PLACEHOLDER = `{
|
||||
key1: 'value1',
|
||||
key2: 'value2',
|
||||
...
|
||||
}`;
|
||||
const SETTINGS_PLACEHOLDER = `{
|
||||
minimumFetchIntervalMillis: 43200000,
|
||||
fetchTimeoutMillis: 5000
|
||||
}`;
|
||||
const SUCCESS_MESSAGE = 'Done <span class="done-checkmark"> ✔</span>';
|
||||
|
||||
let remoteConfig;
|
||||
const outputBox = document.getElementById('output-box');
|
||||
|
||||
window.onload = function() {
|
||||
document.querySelector(
|
||||
'#firebase-config'
|
||||
).placeholder = FB_CONFIG_PLACEHOLDER;
|
||||
document.querySelector('#rc-defaults').placeholder = DEFAULTS_PLACEHOLDER;
|
||||
document.querySelector('#rc-settings').placeholder = SETTINGS_PLACEHOLDER;
|
||||
};
|
||||
|
||||
function initializeFirebase() {
|
||||
const val = document.querySelector('#firebase-config').value;
|
||||
firebase.initializeApp(parseObjFromStr(val));
|
||||
remoteConfig = firebase.remoteConfig();
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
function setDefaults() {
|
||||
remoteConfig.defaultConfig = parseObjFromStr(
|
||||
document.querySelector('#rc-defaults').value
|
||||
);
|
||||
return SUCCESS_MESSAGE;
|
||||
}
|
||||
|
||||
function setSettings() {
|
||||
const newSettings = parseObjFromStr(
|
||||
document.querySelector('#rc-settings').value,
|
||||
true
|
||||
);
|
||||
const currentSettings = remoteConfig.settings;
|
||||
remoteConfig.settings = Object.assign({}, currentSettings, newSettings);
|
||||
return SUCCESS_MESSAGE;
|
||||
}
|
||||
|
||||
function setLogLevel() {
|
||||
const newLogLevel = document.querySelector('#log-level-input').value;
|
||||
remoteConfig.setLogLevel(newLogLevel);
|
||||
return SUCCESS_MESSAGE;
|
||||
}
|
||||
|
||||
function activate() {
|
||||
return remoteConfig.activate();
|
||||
}
|
||||
|
||||
function ensureInitialized() {
|
||||
return remoteConfig.ensureInitialized();
|
||||
}
|
||||
|
||||
// Prefixed to avoid clobbering the browser's fetch function.
|
||||
function rcFetch() {
|
||||
return remoteConfig.fetch();
|
||||
}
|
||||
|
||||
function fetchAndActivate() {
|
||||
return remoteConfig.fetchAndActivate();
|
||||
}
|
||||
|
||||
function getString() {
|
||||
return remoteConfig.getString(getKey());
|
||||
}
|
||||
|
||||
function getBoolean() {
|
||||
return remoteConfig.getBoolean(getKey());
|
||||
}
|
||||
|
||||
function getNumber() {
|
||||
return remoteConfig.getNumber(getKey());
|
||||
}
|
||||
|
||||
function getValue() {
|
||||
return remoteConfig.getValue(getKey());
|
||||
}
|
||||
|
||||
function getAll() {
|
||||
return remoteConfig.getAll();
|
||||
}
|
||||
|
||||
function getFetchTimeMillis() {
|
||||
return remoteConfig.fetchTimeMillis;
|
||||
}
|
||||
|
||||
function getLastFetchStatus() {
|
||||
return remoteConfig.lastFetchStatus;
|
||||
}
|
||||
|
||||
function getSettings() {
|
||||
return remoteConfig.settings;
|
||||
}
|
||||
|
||||
// Helper functions
|
||||
function getKey() {
|
||||
return document.querySelector('#key-input').value;
|
||||
}
|
||||
|
||||
function handlerWrapper(handler) {
|
||||
try {
|
||||
clearOutput();
|
||||
var returnValue = handler();
|
||||
if (returnValue instanceof Promise) {
|
||||
handlePromise(returnValue);
|
||||
} else if (returnValue instanceof Array) {
|
||||
handleArray(returnValue);
|
||||
} else if (returnValue instanceof Object) {
|
||||
handleObject(returnValue);
|
||||
} else {
|
||||
displayOutput(returnValue);
|
||||
}
|
||||
} catch (error) {
|
||||
displayOutputError(error);
|
||||
}
|
||||
}
|
||||
|
||||
function clearOutput() {
|
||||
outputBox.innerHTML = '';
|
||||
}
|
||||
|
||||
function appendOutput(text) {
|
||||
outputBox.innerHTML = outputBox.innerHTML + text;
|
||||
}
|
||||
|
||||
function displayOutput(text) {
|
||||
outputBox.innerHTML = text;
|
||||
}
|
||||
|
||||
function displayOutputError(error) {
|
||||
outputBox.innerHTML = `<span class="error">${error.message || error}<span>`;
|
||||
}
|
||||
|
||||
function handlePromise(prom) {
|
||||
const timerId = setInterval(() => appendOutput('.'), 400);
|
||||
prom
|
||||
.then(res => {
|
||||
clearInterval(timerId);
|
||||
appendOutput(SUCCESS_MESSAGE);
|
||||
if (res != undefined) {
|
||||
appendOutput('<br/>');
|
||||
appendOutput('return value: ' + res);
|
||||
}
|
||||
})
|
||||
.catch(e => {
|
||||
clearInterval(timerId);
|
||||
appendOutput(`<span class="error">${e}</span>`);
|
||||
});
|
||||
}
|
||||
|
||||
function handleArray(ar) {
|
||||
displayOutput(ar.toString());
|
||||
}
|
||||
|
||||
function handleObject(obj) {
|
||||
appendOutput('{');
|
||||
for (const key in obj) {
|
||||
if (obj[key] instanceof Function) {
|
||||
appendOutput(`<br/> ${key}: ${obj[key]()},`);
|
||||
} else if (obj[key] instanceof Object) {
|
||||
appendOutput(key + ': ');
|
||||
handleObject(obj[key]);
|
||||
appendOutput(',');
|
||||
} else {
|
||||
appendOutput(`<br/> ${key}: ${obj[key]},`);
|
||||
}
|
||||
}
|
||||
appendOutput('<br/>}');
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses string received from input elements into objects. These strings are not JSON
|
||||
* compatible.
|
||||
*/
|
||||
function parseObjFromStr(str, valueIsNumber = false) {
|
||||
const obj = {};
|
||||
str
|
||||
.substring(str.indexOf('{') + 1, str.indexOf('}'))
|
||||
.replace(/["']/g, '') // Get rid of curly braces and quotes
|
||||
.trim()
|
||||
.split(/[,]/g) // Results in a string of key value separated by a colon
|
||||
.map(str => str.trim())
|
||||
.forEach(keyValue => {
|
||||
const colIndex = keyValue.indexOf(':');
|
||||
const key = keyValue.substring(0, colIndex);
|
||||
const val = keyValue.substring(colIndex + 1).trim();
|
||||
const value = valueIsNumber ? Number(val) : val;
|
||||
obj[key] = value;
|
||||
});
|
||||
return obj;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"extends": "../../config/tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"outDir": "dist",
|
||||
"resolveJsonModule": true,
|
||||
},
|
||||
"exclude": [
|
||||
"dist/**/*"
|
||||
]
|
||||
}
|
||||
Vendored
+1
-1
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google Inc.
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2017 Google Inc.
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* @license
|
||||
* Copyright 2018 Google Inc.
|
||||
* Copyright 2019 Google Inc.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
|
||||
@@ -186,6 +186,16 @@ toc:
|
||||
- title: "Trace"
|
||||
path: /docs/reference/js/firebase.performance.Trace
|
||||
|
||||
- title: "firebase.remoteconfig"
|
||||
path: /docs/reference/js/firebase.remoteconfig
|
||||
section:
|
||||
- title: "RemoteConfig"
|
||||
path: /docs/reference/js/firebase.remoteconfig.RemoteConfig
|
||||
- title: "Settings"
|
||||
path: /docs/reference/js/firebase.remoteconfig.Settings
|
||||
- title: "Value"
|
||||
path: /docs/reference/js/firebase.remoteconfig.Value
|
||||
|
||||
- title: "firebase.storage"
|
||||
path: /docs/reference/js/firebase.storage
|
||||
section:
|
||||
|
||||
@@ -14568,6 +14568,11 @@ [email protected]:
|
||||
typedoc-default-themes "^0.6.0"
|
||||
typescript "3.5.x"
|
||||
|
||||
[email protected]:
|
||||
version "3.4.5"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-3.4.5.tgz#2d2618d10bb566572b8d7aad5180d84257d70a99"
|
||||
integrity sha512-YycBxUb49UUhdNMU5aJ7z5Ej2XGmaIBL0x34vZ82fn3hGvD+bgrMrVDpatgz2f7YxUMJxMkbWxJZeAvDxVe7Vw==
|
||||
|
||||
[email protected], [email protected]:
|
||||
version "3.5.3"
|
||||
resolved "https://registry.npmjs.org/typescript/-/typescript-3.5.3.tgz#c830f657f93f1ea846819e929092f5fe5983e977"
|
||||
|
||||
Reference in New Issue
Block a user