Add Remote Config SDK (#2193)

This commit is contained in:
Feiyang
2019-09-25 12:12:16 -07:00
committed by GitHub
parent 8b24f0da9f
commit 3468419954
46 changed files with 4208 additions and 7 deletions
+1 -1
View File
@@ -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],
+199 -1
View File
@@ -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
*/
+5 -2
View File
@@ -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"
]
}
+18
View File
@@ -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"
}
+3
View File
@@ -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
View File
@@ -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';
+24
View File
@@ -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/**/*"
]
}
+6
View File
@@ -0,0 +1,6 @@
{
"extends": "../../config/.eslintrc.json",
"parserOptions": {
"project": "tsconfig.json"
}
}
+1
View File
@@ -0,0 +1 @@
# This file is left intentionally blank
+27
View File
@@ -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
+123
View File
@@ -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;
}
}
+36
View File
@@ -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;
+69
View File
@@ -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"
}
}
+83
View File
@@ -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);
}
}
}
+97
View File
@@ -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;
}
+38
View File
@@ -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.
);
}
+225
View File
@@ -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);
}
}
+57
View File
@@ -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'
);
});
});
});
+26
View File
@@ -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);
});
});
});
+78
View File
@@ -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');
});
});
});
+154
View File
@@ -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>
+224
View File
@@ -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"> &#10004;</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;
}
+10
View File
@@ -0,0 +1,10 @@
{
"extends": "../../config/tsconfig.base.json",
"compilerOptions": {
"outDir": "dist",
"resolveJsonModule": true,
},
"exclude": [
"dist/**/*"
]
}
+1 -1
View File
@@ -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 -1
View File
@@ -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 -1
View File
@@ -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:
+5
View File
@@ -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"