diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index f9531f0726d..dc51205a890 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -13,9 +13,9 @@ # Used for approving minor changes, large-scale refactorings, and emergency situations. # (secret team to avoid review requests) # -# - @Feiyang1 -# - @hiranya911 -# - @mikelehen +# - @Feiyang1 +# - @hiranya911 +# - @mikelehen # - @bojeil-google # - @depoll # - @hsubox76 @@ -27,9 +27,9 @@ # Used for approving firestore changes. # (secret team to avoid review requests) # -# - @mikelehen -# - @schmidt-sebastian -# - @wilhuff +# - @mikelehen +# - @schmidt-sebastian +# - @wilhuff # - @gsoltis # - @var-const # - @rsgowman @@ -67,3 +67,11 @@ packages/testing @tonymeng @ryanpbrewster @firebase/jssdk-global-approvers # RxFire Code packages/rxfire @davideast @jamesdaniels @firebase/jssdk-global-approvers + +# Installations +packages/installations @mmermerkaya @andirayo @firebase/jssdk-global-approvers +packages/installations-types @mmermerkaya @andirayo @firebase/jssdk-global-approvers + +# Performance Code +packages/performance @alikn @zijianjoy @firebase/jssdk-global-approvers +packages/performance-types @alikn @zijianjoy @firebase/jssdk-global-approvers diff --git a/integration/firestore/package.json b/integration/firestore/package.json index f6b1a8179b2..faf34e043a7 100644 --- a/integration/firestore/package.json +++ b/integration/firestore/package.json @@ -4,9 +4,9 @@ "private": true, "scripts": { "build": "gulp compile-tests", - "pretest:manual": "npm run build", - "pretest:debug": "npm run build", - "test": "echo 'Automated tests temporarily disabled, run `npm run test:manual` to execute these tests'", + "pretest:manual": "yarn build", + "pretest:debug": "yarn build", + "test": "echo 'Automated tests temporarily disabled, run `yarn test:manual` to execute these tests'", "test:manual": "karma start --single-run", "test:debug": "karma start --auto-watch --browsers Chrome" }, diff --git a/integration/shared/namespaceDefinition.json b/integration/shared/namespaceDefinition.json index 0af8a437e90..827d69c6115 100644 --- a/integration/shared/namespaceDefinition.json +++ b/integration/shared/namespaceDefinition.json @@ -95,26 +95,6 @@ }, "deepExtend": { "__type": "function" - }, - "Promise": { - "__type": "function", - "resolve": { - "__type": "function" - }, - "reject": { - "__type": "function" - }, - "all": { - "__type": "function" - }, - "prototype": { - "then": { - "__type": "function" - }, - "catch": { - "__type": "function" - } - } } }, "auth": { diff --git a/package.json b/package.json index 9534ea1610f..1e6573809cb 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "firebase", "functions", "realtime", - "storage" + "storage", + "performance" ], "scripts": { "dev": "lerna run --parallel --scope @firebase/* --scope firebase --scope rxfire dev", @@ -32,7 +33,7 @@ "test:saucelabs": "karma start config/karma.saucelabs.js --single-run", "docgen:js": "node scripts/docgen/generate-docs.js --api js", "docgen:node": "node scripts/docgen/generate-docs.js --api node", - "docgen": "npm run docgen:js; npm run docgen:node", + "docgen": "yarn docgen:js; yarn docgen:node", "prettier": "prettier --config .prettierrc --write '**/*.{ts,js}'" }, "repository": { diff --git a/packages/app-types/index.d.ts b/packages/app-types/index.d.ts index 0494f88367d..838724347f9 100644 --- a/packages/app-types/index.d.ts +++ b/packages/app-types/index.d.ts @@ -22,6 +22,7 @@ export type FirebaseOptions = { projectId?: string; storageBucket?: string; messagingSenderId?: string; + appId?: string; [name: string]: any; }; diff --git a/packages/app/index.lite.ts b/packages/app/index.lite.ts new file mode 100644 index 00000000000..2fe062f9946 --- /dev/null +++ b/packages/app/index.lite.ts @@ -0,0 +1,22 @@ +/** + * @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 { createFirebaseNamespaceLite } from './src/lite/firebaseNamespaceLite'; + +export const firebase = createFirebaseNamespaceLite(); + +export default firebase; diff --git a/packages/app/index.ts b/packages/app/index.ts index 046059673ac..990b18c5187 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -40,6 +40,22 @@ to false and "main" to true: https://github.com/rollup/rollup-plugin-node-resolve `); +// Firebase Lite detection +if (self && 'firebase' in self) { + console.warn(` + Warning: Firebase is already defined in the global scope. Please make sure + Firebase library is only loaded once. + `); + + const sdkVersion = ((self as any).firebase as FirebaseNamespace).SDK_VERSION; + if (sdkVersion && sdkVersion.indexOf('LITE') >= 0) { + console.warn(` + Warning: You are trying to load Firebase while using Firebase Performance standalone script. + You should load Firebase Performance with this instance of Firebase to avoid loading duplicate code. + `); + } +} + export const firebase = createFirebaseNamespace(); export default firebase; diff --git a/packages/app/package.json b/packages/app/package.json index fd06f1c88f8..2c63f2ee220 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -7,6 +7,9 @@ "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", "react-native": "dist/index.rn.cjs.js", + "esm2017": "dist/index.esm2017.js", + "lite": "dist/index.lite.js", + "lite-esm2017": "dist/index.lite.esm2017.js", "files": [ "dist" ], @@ -17,7 +20,7 @@ "test:browser": "karma start --single-run", "test:browser:debug": "karma start --browsers Chrome --auto-watch", "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --opts ../../config/mocha.node.opts", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/app/rollup.config.js b/packages/app/rollup.config.js index 14b9de543e8..d55d9e084b6 100644 --- a/packages/app/rollup.config.js +++ b/packages/app/rollup.config.js @@ -15,15 +15,23 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; import replace from 'rollup-plugin-replace'; +import typescript from 'typescript'; import pkg from './package.json'; import firebasePkg from '../firebase/package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript }), replace({ delimiters: ['${', '}'], @@ -33,18 +41,14 @@ const plugins = [ }) ]; -const deps = Object.keys( - Object.assign({}, pkg.peerDependencies, pkg.dependencies) -); - -export default [ +const es5Builds = [ { input: 'index.ts', output: [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, { @@ -54,7 +58,7 @@ export default [ format: 'cjs', sourcemap: true }, - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, { @@ -64,10 +68,68 @@ export default [ format: 'cjs', sourcemap: true }, - plugins, + plugins: es5BuildPlugins, external: id => [...deps, 'react-native'].some( dep => id === dep || id.startsWith(`${dep}/`) ) + }, + { + input: 'index.lite.ts', + output: { + file: pkg.lite, + 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' + } + } + }), + replace({ + delimiters: ['${', '}'], + values: { + JSCORE_VERSION: firebasePkg.version + } + }) +]; + +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}/`)) + }, + { + input: 'index.lite.ts', + output: { + file: pkg['lite-esm2017'], + format: 'es', + sourcemap: true + }, + plugins: es2017BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; + +export default [...es5Builds, ...es2017Builds]; diff --git a/packages/app/src/constants.ts b/packages/app/src/constants.ts new file mode 100644 index 00000000000..2c6101879ea --- /dev/null +++ b/packages/app/src/constants.ts @@ -0,0 +1,18 @@ +/** + * @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 const DEFAULT_ENTRY_NAME = '[DEFAULT]'; diff --git a/packages/app/src/errors.ts b/packages/app/src/errors.ts index 80f15999672..1094bf8152f 100644 --- a/packages/app/src/errors.ts +++ b/packages/app/src/errors.ts @@ -26,7 +26,7 @@ export const enum AppError { INVALID_APP_ARGUMENT = 'invalid-app-argument' } -const errors: ErrorMap = { +const ERRORS: ErrorMap = { [AppError.NO_APP]: "No Firebase App '{$name}' has been created - " + 'call Firebase App.initializeApp()', @@ -40,7 +40,7 @@ const errors: ErrorMap = { 'Firebase App instance.' }; -const appErrors = new ErrorFactory('app', 'Firebase', errors); +const appErrors = new ErrorFactory('app', 'Firebase', ERRORS); export function error(code: AppError, args?: { [name: string]: any }) { throw appErrors.create(code, args); diff --git a/packages/app/src/firebaseApp.ts b/packages/app/src/firebaseApp.ts index 968ed1ce7c0..545178c9b98 100644 --- a/packages/app/src/firebaseApp.ts +++ b/packages/app/src/firebaseApp.ts @@ -18,18 +18,23 @@ import { FirebaseApp, FirebaseOptions, - FirebaseNamespace, FirebaseAppConfig } from '@firebase/app-types'; import { _FirebaseApp, _FirebaseNamespace, - FirebaseService + FirebaseService, + FirebaseAppInternals } from '@firebase/app-types/private'; import { deepCopy, deepExtend } from '@firebase/util'; import { error, AppError } from './errors'; +import { DEFAULT_ENTRY_NAME } from './constants'; -export const DEFAULT_ENTRY_NAME = '[DEFAULT]'; +interface ServicesCache { + [name: string]: { + [serviceName: string]: FirebaseService; + }; +} // An array to capture listeners before the true auth functions // exist @@ -40,25 +45,21 @@ let tokenListeners: any[] = []; * a shared authentication state. */ export class FirebaseAppImpl implements FirebaseApp { - private options_: FirebaseOptions; - private name_: string; + private readonly options_: FirebaseOptions; + private readonly name_: string; private isDeleted_ = false; - private services_: { - [name: string]: { - [serviceName: string]: FirebaseService; - }; - } = {}; - private _automaticDataCollectionEnabled: boolean; + private services_: ServicesCache = {}; + private automaticDataCollectionEnabled_: boolean; - public INTERNAL; + INTERNAL: FirebaseAppInternals; constructor( options: FirebaseOptions, config: FirebaseAppConfig, - private firebase_: FirebaseNamespace + private readonly firebase_: _FirebaseNamespace ) { this.name_ = config.name!; - this._automaticDataCollectionEnabled = + this.automaticDataCollectionEnabled_ = config.automaticDataCollectionEnabled || false; this.options_ = deepCopy(options); this.INTERNAL = { @@ -79,12 +80,12 @@ export class FirebaseAppImpl implements FirebaseApp { get automaticDataCollectionEnabled(): boolean { this.checkDestroyed_(); - return this._automaticDataCollectionEnabled; + return this.automaticDataCollectionEnabled_; } set automaticDataCollectionEnabled(val) { this.checkDestroyed_(); - this._automaticDataCollectionEnabled = val; + this.automaticDataCollectionEnabled_ = val; } get name(): string { @@ -103,16 +104,18 @@ export class FirebaseAppImpl implements FirebaseApp { resolve(); }) .then(() => { - (this.firebase_ as _FirebaseNamespace).INTERNAL.removeApp(this.name_); - let services: FirebaseService[] = []; - Object.keys(this.services_).forEach(serviceKey => { - Object.keys(this.services_[serviceKey]).forEach(instanceKey => { + this.firebase_.INTERNAL.removeApp(this.name_); + const services: FirebaseService[] = []; + + for (const serviceKey of Object.keys(this.services_)) { + for (const instanceKey of Object.keys(this.services_[serviceKey])) { services.push(this.services_[serviceKey][instanceKey]); - }); - }); + } + } + return Promise.all( services.map(service => { - return service.INTERNAL!.delete(); + return service.INTERNAL.delete(); }) ); }) @@ -157,9 +160,11 @@ export class FirebaseAppImpl implements FirebaseApp { instanceIdentifier !== DEFAULT_ENTRY_NAME ? instanceIdentifier : undefined; - const service = (this.firebase_ as _FirebaseNamespace).INTERNAL.factories[ - name - ](this, this.extendApp.bind(this), instanceSpecifier); + const service = this.firebase_.INTERNAL.factories[name]( + this, + this.extendApp.bind(this), + instanceSpecifier + ); this.services_[name][instanceIdentifier] = service; } diff --git a/packages/app/src/firebaseNamespace.ts b/packages/app/src/firebaseNamespace.ts index 06c89ee3cfb..9789c3554fc 100644 --- a/packages/app/src/firebaseNamespace.ts +++ b/packages/app/src/firebaseNamespace.ts @@ -15,32 +15,11 @@ * limitations under the License. */ -import { - FirebaseApp, - FirebaseOptions, - FirebaseNamespace, - FirebaseAppConfig -} from '@firebase/app-types'; -import { - _FirebaseApp, - _FirebaseNamespace, - FirebaseService, - FirebaseServiceFactory, - FirebaseServiceNamespace, - AppHook -} from '@firebase/app-types/private'; -import { - createSubscribe, - deepExtend, - ErrorFactory, - patchProperty -} from '@firebase/util'; -import { FirebaseAppImpl, DEFAULT_ENTRY_NAME } from './firebaseApp'; -import { error, AppError } from './errors'; - -const contains = function(obj, key) { - return Object.prototype.hasOwnProperty.call(obj, key); -}; +import { FirebaseNamespace } from '@firebase/app-types'; +import { _FirebaseApp, _FirebaseNamespace } from '@firebase/app-types/private'; +import { createSubscribe, deepExtend, ErrorFactory } from '@firebase/util'; +import { FirebaseAppImpl } from './firebaseApp'; +import { createFirebaseNamespaceCore } from './firebaseNamespaceCore'; /** * Return a firebase namespace object. @@ -50,220 +29,24 @@ const contains = function(obj, key) { * in unit tests. */ export function createFirebaseNamespace(): FirebaseNamespace { - let apps_: { [name: string]: FirebaseApp } = {}; - let factories: { [service: string]: FirebaseServiceFactory } = {}; - let appHooks: { [service: string]: AppHook } = {}; - - // A namespace is a plain JavaScript Object. - let namespace = { - // Hack to prevent Babel from modifying the object returned - // as the firebase namespace. - __esModule: true, - initializeApp: initializeApp, - app: app as any, - apps: null as any, - Promise: Promise, - SDK_VERSION: '${JSCORE_VERSION}', - INTERNAL: { - registerService: registerService, - createFirebaseNamespace: createFirebaseNamespace, - extendNamespace: extendNamespace, - createSubscribe: createSubscribe, - ErrorFactory: ErrorFactory, - removeApp: removeApp, - factories: factories, - useAsService: useAsService, - Promise: Promise, - deepExtend: deepExtend - } + const namespace = createFirebaseNamespaceCore(FirebaseAppImpl); + (namespace as _FirebaseNamespace).INTERNAL = { + ...(namespace as _FirebaseNamespace).INTERNAL, + createFirebaseNamespace: createFirebaseNamespace, + extendNamespace: extendNamespace, + createSubscribe: createSubscribe, + ErrorFactory: ErrorFactory, + deepExtend: deepExtend }; - // Inject a circular default export to allow Babel users who were previously - // using: - // - // import firebase from 'firebase'; - // which becomes: var firebase = require('firebase').default; - // - // instead of - // - // import * as firebase from 'firebase'; - // which becomes: var firebase = require('firebase'); - patchProperty(namespace, 'default', namespace); - - // firebase.apps is a read-only getter. - Object.defineProperty(namespace, 'apps', { - get: getApps - }); - - /** - * Called by App.delete() - but before any services associated with the App - * are deleted. - */ - function removeApp(name: string): void { - let app = apps_[name]; - callAppHooks(app, 'delete'); - delete apps_[name]; - } - - /** - * Get the App object for a given name (or DEFAULT). - */ - function app(name?: string): FirebaseApp { - name = name || DEFAULT_ENTRY_NAME; - if (!contains(apps_, name)) { - error(AppError.NO_APP, { name: name }); - } - return apps_[name]; - } - - patchProperty(app, 'App', FirebaseAppImpl); - - /** - * Create a new App instance (name must be unique). - */ - function initializeApp( - options: FirebaseOptions, - config?: FirebaseAppConfig - ): FirebaseApp; - function initializeApp(options: FirebaseOptions, name?: string): FirebaseApp; - function initializeApp(options: FirebaseOptions, rawConfig = {}) { - if (typeof rawConfig !== 'object' || rawConfig === null) { - const name = rawConfig; - rawConfig = { name }; - } - - const config = rawConfig as FirebaseAppConfig; - - if (config.name === undefined) { - config.name = DEFAULT_ENTRY_NAME; - } - - const { name } = config; - - if (typeof name !== 'string' || !name) { - error(AppError.BAD_APP_NAME, { name: name + '' }); - } - - if (contains(apps_, name)) { - error(AppError.DUPLICATE_APP, { name: name }); - } - - let app = new FirebaseAppImpl( - options, - config!, - namespace as FirebaseNamespace - ); - - apps_[name!] = app; - callAppHooks(app, 'create'); - - return app; - } - - /* - * Return an array of all the non-deleted FirebaseApps. - */ - function getApps(): FirebaseApp[] { - // Make a copy so caller cannot mutate the apps list. - return Object.keys(apps_).map(name => apps_[name]); - } - - /* - * Register a Firebase Service. - * - * firebase.INTERNAL.registerService() - * - * TODO: Implement serviceProperties. - */ - function registerService( - name: string, - createService: FirebaseServiceFactory, - serviceProperties?: { [prop: string]: any }, - appHook?: AppHook, - allowMultipleInstances?: boolean - ): FirebaseServiceNamespace { - // Cannot re-register a service that already exists - if (factories[name]) { - error(AppError.DUPLICATE_SERVICE, { name: name }); - } - - // Capture the service factory for later service instantiation - factories[name] = createService; - - // Capture the appHook, if passed - if (appHook) { - appHooks[name] = appHook; - - // Run the **new** app hook on all existing apps - getApps().forEach(app => { - appHook('create', app); - }); - } - - // The Service namespace is an accessor function ... - const serviceNamespace = (appArg: FirebaseApp = app()) => { - if (typeof (appArg as any)[name] !== 'function') { - // Invalid argument. - // This happens in the following case: firebase.storage('gs:/') - error(AppError.INVALID_APP_ARGUMENT, { name: name }); - } - - // Forward service instance lookup to the FirebaseApp. - return (appArg as any)[name](); - }; - - // ... and a container for service-level properties. - if (serviceProperties !== undefined) { - deepExtend(serviceNamespace, serviceProperties); - } - - // Monkey-patch the serviceNamespace onto the firebase namespace - (namespace as any)[name] = serviceNamespace; - - // Patch the FirebaseAppImpl prototype - FirebaseAppImpl.prototype[name] = function(...args) { - const serviceFxn = this._getService.bind(this, name); - return serviceFxn.apply(this, allowMultipleInstances ? args : []); - }; - - return serviceNamespace; - } - /** * Patch the top-level firebase namespace with additional properties. * * firebase.INTERNAL.extendNamespace() */ - function extendNamespace(props: { [prop: string]: any }): void { + function extendNamespace(props: { [prop: string]: unknown }): void { deepExtend(namespace, props); } - function callAppHooks(app: FirebaseApp, eventName: string) { - Object.keys(factories).forEach(serviceName => { - // Ignore virtual services - let factoryName = useAsService(app, serviceName); - if (factoryName === null) { - return; - } - - if (appHooks[factoryName]) { - appHooks[factoryName](eventName, app); - } - }); - } - - // Map the requested service to a registered service name - // (used to map auth to serverAuth service when needed). - function useAsService(app: FirebaseApp, name: string): string | null { - if (name === 'serverAuth') { - return null; - } - - let useService = name; - let options = app.options; - - return useService; - } - - return (namespace as any) as FirebaseNamespace; + return namespace; } diff --git a/packages/app/src/firebaseNamespaceCore.ts b/packages/app/src/firebaseNamespaceCore.ts new file mode 100644 index 00000000000..c4156815f11 --- /dev/null +++ b/packages/app/src/firebaseNamespaceCore.ts @@ -0,0 +1,253 @@ +/** + * @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, + FirebaseOptions, + FirebaseNamespace, + FirebaseAppConfig +} from '@firebase/app-types'; +import { + _FirebaseApp, + _FirebaseNamespace, + FirebaseService, + FirebaseServiceFactory, + FirebaseServiceNamespace, + AppHook +} from '@firebase/app-types/private'; +import { deepExtend, patchProperty } from '@firebase/util'; +import { FirebaseAppImpl } from './firebaseApp'; +import { error, AppError } from './errors'; +import { FirebaseAppLiteImpl } from './lite/firebaseAppLite'; +import { DEFAULT_ENTRY_NAME } from './constants'; + +function contains(obj: object, key: string) { + return Object.prototype.hasOwnProperty.call(obj, key); +} + +/** + * Because auth can't share code with other components, we attach the utility functions + * in an internal namespace to share code. + * This function return a firebase namespace object without + * any utility functions, so it can be shared between the regular firebaseNamespace and + * the lite version. + */ +export function createFirebaseNamespaceCore( + firebaseAppImpl: typeof FirebaseAppImpl | typeof FirebaseAppLiteImpl +): FirebaseNamespace { + const apps: { [name: string]: FirebaseApp } = {}; + const factories: { [service: string]: FirebaseServiceFactory } = {}; + const appHooks: { [service: string]: AppHook } = {}; + + // A namespace is a plain JavaScript Object. + const namespace: FirebaseNamespace = { + // Hack to prevent Babel from modifying the object returned + // as the firebase namespace. + // @ts-ignore + __esModule: true, + initializeApp: initializeApp, + app: app as any, + apps: null as any, + Promise: Promise, + SDK_VERSION: '${JSCORE_VERSION}', + INTERNAL: { + registerService, + removeApp, + factories, + useAsService + } + }; + + // Inject a circular default export to allow Babel users who were previously + // using: + // + // import firebase from 'firebase'; + // which becomes: var firebase = require('firebase').default; + // + // instead of + // + // import * as firebase from 'firebase'; + // which becomes: var firebase = require('firebase'); + patchProperty(namespace, 'default', namespace); + + // firebase.apps is a read-only getter. + Object.defineProperty(namespace, 'apps', { + get: getApps + }); + + /** + * Called by App.delete() - but before any services associated with the App + * are deleted. + */ + function removeApp(name: string): void { + const app = apps[name]; + callAppHooks(app, 'delete'); + delete apps[name]; + } + + /** + * Get the App object for a given name (or DEFAULT). + */ + function app(name?: string): FirebaseApp { + name = name || DEFAULT_ENTRY_NAME; + if (!contains(apps, name)) { + error(AppError.NO_APP, { name: name }); + } + return apps[name]; + } + + patchProperty(app, 'App', firebaseAppImpl); + /** + * Create a new App instance (name must be unique). + */ + function initializeApp( + options: FirebaseOptions, + config?: FirebaseAppConfig + ): FirebaseApp; + function initializeApp(options: FirebaseOptions, name?: string): FirebaseApp; + function initializeApp(options: FirebaseOptions, rawConfig = {}) { + if (typeof rawConfig !== 'object' || rawConfig === null) { + const name = rawConfig; + rawConfig = { name }; + } + + const config = rawConfig as FirebaseAppConfig; + + if (config.name === undefined) { + config.name = DEFAULT_ENTRY_NAME; + } + + const { name } = config; + + if (typeof name !== 'string' || !name) { + error(AppError.BAD_APP_NAME, { name: String(name) }); + } + + if (contains(apps, name)) { + error(AppError.DUPLICATE_APP, { name: name }); + } + + const app = new firebaseAppImpl( + options, + config, + namespace as _FirebaseNamespace + ); + + apps[name] = app; + callAppHooks(app, 'create'); + + return app; + } + + /* + * Return an array of all the non-deleted FirebaseApps. + */ + function getApps(): FirebaseApp[] { + // Make a copy so caller cannot mutate the apps list. + return Object.keys(apps).map(name => apps[name]); + } + + /* + * Register a Firebase Service. + * + * firebase.INTERNAL.registerService() + * + * TODO: Implement serviceProperties. + */ + function registerService( + name: string, + createService: FirebaseServiceFactory, + serviceProperties?: { [prop: string]: unknown }, + appHook?: AppHook, + allowMultipleInstances = false + ): FirebaseServiceNamespace { + // Cannot re-register a service that already exists + if (factories[name]) { + error(AppError.DUPLICATE_SERVICE, { name: name }); + } + + // Capture the service factory for later service instantiation + factories[name] = createService; + + // Capture the appHook, if passed + if (appHook) { + appHooks[name] = appHook; + + // Run the **new** app hook on all existing apps + getApps().forEach(app => { + appHook('create', app); + }); + } + + // The Service namespace is an accessor function ... + function serviceNamespace(appArg: FirebaseApp = app()) { + if (typeof appArg[name] !== 'function') { + // Invalid argument. + // This happens in the following case: firebase.storage('gs:/') + error(AppError.INVALID_APP_ARGUMENT, { name: name }); + } + + // Forward service instance lookup to the FirebaseApp. + return appArg[name](); + } + + // ... and a container for service-level properties. + if (serviceProperties !== undefined) { + deepExtend(serviceNamespace, serviceProperties); + } + + // Monkey-patch the serviceNamespace onto the firebase namespace + namespace[name] = serviceNamespace; + + // Patch the FirebaseAppImpl prototype + firebaseAppImpl.prototype[name] = function(...args) { + const serviceFxn = this._getService.bind(this, name); + return serviceFxn.apply(this, allowMultipleInstances ? args : []); + }; + + return serviceNamespace; + } + + function callAppHooks(app: FirebaseApp, eventName: string) { + for (const serviceName of Object.keys(factories)) { + // Ignore virtual services + const factoryName = useAsService(app, serviceName); + if (factoryName === null) { + return; + } + + if (appHooks[factoryName]) { + appHooks[factoryName](eventName, app); + } + } + } + + // Map the requested service to a registered service name + // (used to map auth to serverAuth service when needed). + function useAsService(app: FirebaseApp, name: string): string | null { + if (name === 'serverAuth') { + return null; + } + + const useService = name; + const options = app.options; + + return useService; + } + + return namespace; +} diff --git a/packages/app/src/lite/firebaseAppLite.ts b/packages/app/src/lite/firebaseAppLite.ts new file mode 100644 index 00000000000..080cf2ec5b8 --- /dev/null +++ b/packages/app/src/lite/firebaseAppLite.ts @@ -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. + */ + +import { + FirebaseApp, + FirebaseOptions, + FirebaseAppConfig +} from '@firebase/app-types'; +import { + _FirebaseApp, + _FirebaseNamespace, + FirebaseService +} from '@firebase/app-types/private'; +import { deepCopy, deepExtend } from '@firebase/util'; +import { error, AppError } from '../errors'; +import { DEFAULT_ENTRY_NAME } from '../constants'; + +interface ServicesCache { + [name: string]: { + [serviceName: string]: FirebaseService; + }; +} + +/** + * Global context object for a collection of services using + * a shared authentication state. + */ +export class FirebaseAppLiteImpl implements FirebaseApp { + private readonly options_: FirebaseOptions; + private readonly name_: string; + private isDeleted_ = false; + private services_: ServicesCache = {}; + private automaticDataCollectionEnabled_: boolean; + + // lite version has an empty INTERNAL namespace + readonly INTERNAL = {}; + + constructor( + options: FirebaseOptions, + config: FirebaseAppConfig, + private readonly firebase_: _FirebaseNamespace + ) { + this.name_ = config.name!; + this.automaticDataCollectionEnabled_ = + config.automaticDataCollectionEnabled || false; + this.options_ = deepCopy(options); + } + + get automaticDataCollectionEnabled(): boolean { + this.checkDestroyed_(); + return this.automaticDataCollectionEnabled_; + } + + set automaticDataCollectionEnabled(val) { + this.checkDestroyed_(); + this.automaticDataCollectionEnabled_ = val; + } + + get name(): string { + this.checkDestroyed_(); + return this.name_; + } + + get options(): FirebaseOptions { + this.checkDestroyed_(); + return this.options_; + } + + delete(): Promise { + return new Promise(resolve => { + this.checkDestroyed_(); + resolve(); + }) + .then(() => { + this.firebase_.INTERNAL.removeApp(this.name_); + const services: FirebaseService[] = []; + + for (const serviceKey of Object.keys(this.services_)) { + for (const instanceKey of Object.keys(this.services_[serviceKey])) { + services.push(this.services_[serviceKey][instanceKey]); + } + } + + return Promise.all( + services.map(service => { + return service.INTERNAL.delete(); + }) + ); + }) + .then( + (): void => { + this.isDeleted_ = true; + this.services_ = {}; + } + ); + } + + /** + * Return a service instance associated with this app (creating it + * on demand), identified by the passed instanceIdentifier. + * + * NOTE: Currently storage is the only one that is leveraging this + * functionality. They invoke it by calling: + * + * ```javascript + * firebase.app().storage('STORAGE BUCKET ID') + * ``` + * + * The service name is passed to this already + * @internal + */ + _getService( + name: string, + instanceIdentifier: string = DEFAULT_ENTRY_NAME + ): FirebaseService { + this.checkDestroyed_(); + + if (!this.services_[name]) { + this.services_[name] = {}; + } + + if (!this.services_[name][instanceIdentifier]) { + /** + * If a custom instance has been defined (i.e. not '[DEFAULT]') + * then we will pass that instance on, otherwise we pass `null` + */ + const instanceSpecifier = + instanceIdentifier !== DEFAULT_ENTRY_NAME + ? instanceIdentifier + : undefined; + const service = this.firebase_.INTERNAL.factories[name]( + this, + this.extendApp.bind(this), + instanceSpecifier + ); + this.services_[name][instanceIdentifier] = service; + } + + return this.services_[name][instanceIdentifier]; + } + + /** + * Callback function used to extend an App instance at the time + * of service instance creation. + */ + private extendApp(props: { [name: string]: any }): void { + // Copy the object onto the FirebaseAppImpl prototype + deepExtend(this, props); + } + + /** + * This function will throw an Error if the App has already been deleted - + * use before performing API actions on the App. + */ + private checkDestroyed_(): void { + if (this.isDeleted_) { + error(AppError.APP_DELETED, { name: this.name_ }); + } + } +} diff --git a/packages/app/src/lite/firebaseNamespaceLite.ts b/packages/app/src/lite/firebaseNamespaceLite.ts new file mode 100644 index 00000000000..cd6d4eb08c0 --- /dev/null +++ b/packages/app/src/lite/firebaseNamespaceLite.ts @@ -0,0 +1,63 @@ +/** + * @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 { FirebaseNamespace } from '@firebase/app-types'; +import { + _FirebaseApp, + _FirebaseNamespace, + FirebaseServiceFactory, + AppHook +} from '@firebase/app-types/private'; +import { FirebaseAppLiteImpl } from './firebaseAppLite'; +import { createFirebaseNamespaceCore } from '../firebaseNamespaceCore'; + +export function createFirebaseNamespaceLite(): FirebaseNamespace { + const namespace = createFirebaseNamespaceCore(FirebaseAppLiteImpl); + + namespace.SDK_VERSION = '${JSCORE_VERSION}_LITE'; + + const registerService = (namespace as _FirebaseNamespace).INTERNAL + .registerService; + (namespace as _FirebaseNamespace).INTERNAL.registerService = registerServiceForLite; + + /** + * This is a special implementation, so it only works with performance. + * only allow performance SDK to register. + */ + function registerServiceForLite( + name: string, + createService: FirebaseServiceFactory, + serviceProperties?: { [prop: string]: any }, + appHook?: AppHook, + allowMultipleInstances?: boolean + ) { + // only allow performance to register with firebase lite + if (name !== 'performance' && name !== 'installations') { + throw Error(`${name} cannot register with the standalone perf instance`); + } + + return registerService( + name, + createService, + serviceProperties, + appHook, + allowMultipleInstances + ); + } + + return namespace; +} diff --git a/packages/app/test/firebaseApp.test.ts b/packages/app/test/firebaseApp.test.ts index 8d20221e620..81830ca1f4b 100644 --- a/packages/app/test/firebaseApp.test.ts +++ b/packages/app/test/firebaseApp.test.ts @@ -22,391 +22,445 @@ import { FirebaseService } from '@firebase/app-types/private'; import { createFirebaseNamespace } from '../src/firebaseNamespace'; +import { createFirebaseNamespaceLite } from '../src/lite/firebaseNamespaceLite'; import { assert } from 'chai'; -describe('Firebase App Class', () => { - let firebase: FirebaseNamespace; +executeFirebaseTests(); +executeFirebaseLiteTests(); - beforeEach(() => { - firebase = createFirebaseNamespace(); - }); - - it('No initial apps.', () => { - assert.equal(firebase.apps.length, 0); - }); +function executeFirebaseTests() { + firebaseAppTests('Firebase App Tests', createFirebaseNamespace); - it('Can initialize DEFAULT App.', () => { - let app = firebase.initializeApp({}); - assert.equal(firebase.apps.length, 1); - assert.strictEqual(app, firebase.apps[0]); - assert.equal(app.name, '[DEFAULT]'); - assert.strictEqual(firebase.app(), app); - assert.strictEqual(firebase.app('[DEFAULT]'), app); - }); + describe('Firebase Service Registration', () => { + let firebase: FirebaseNamespace; - it('Can get options of App.', () => { - const options = { test: 'option' }; - let app = firebase.initializeApp(options); - assert.deepEqual(app.options as any, options as any); - }); + beforeEach(() => { + firebase = createFirebaseNamespace(); + }); + it('Register App Hook', done => { + const events = ['create', 'delete']; + let hookEvents = 0; + let app: FirebaseApp; + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'test', + (app: FirebaseApp) => { + return new TestService(app); + }, + undefined, + (event: string, app: FirebaseApp) => { + assert.equal(event, events[hookEvents]); + hookEvents += 1; + if (hookEvents === events.length) { + done(); + } + } + ); + app = firebase.initializeApp({}); + // Ensure the hook is called synchronously + assert.equal(hookEvents, 1); + app.delete(); + }); - it('Can delete App.', () => { - let app = firebase.initializeApp({}); - assert.equal(firebase.apps.length, 1); - return app.delete().then(() => { - assert.equal(firebase.apps.length, 0); + it('Only calls createService on first use (per app).', () => { + let registrations = 0; + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'test', + (app: FirebaseApp) => { + registrations += 1; + return new TestService(app); + } + ); + let app = firebase.initializeApp({}); + assert.equal(registrations, 0); + (firebase as any).test(); + assert.equal(registrations, 1); + (firebase as any).test(); + assert.equal(registrations, 1); + (firebase as any).test(app); + assert.equal(registrations, 1); + (app as any).test(); + assert.equal(registrations, 1); + + app = firebase.initializeApp({}, 'second'); + assert.equal(registrations, 1); + (app as any).test(); + assert.equal(registrations, 2); }); - }); - it('Register App Hook', done => { - let events = ['create', 'delete']; - let hookEvents = 0; - let app: FirebaseApp; - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => { - return new TestService(app); - }, - undefined, - (event: string, app: FirebaseApp) => { - assert.equal(event, events[hookEvents]); - hookEvents += 1; - if (hookEvents === events.length) { - done(); + it('Can lazy load a service', () => { + let registrations = 0; + + const app1 = firebase.initializeApp({}); + assert.isUndefined((app1 as any).lazyService); + + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'lazyService', + (app: FirebaseApp) => { + registrations += 1; + return new TestService(app); } - } - ); - app = firebase.initializeApp({}); - // Ensure the hook is called synchronously - assert.equal(hookEvents, 1); - app.delete(); - }); + ); - it('Can create named App.', () => { - let app = firebase.initializeApp({}, 'my-app'); - assert.equal(firebase.apps.length, 1); - assert.equal(app.name, 'my-app'); - assert.strictEqual(firebase.app('my-app'), app); - }); + assert.isDefined((app1 as any).lazyService); - it('Can create named App and DEFAULT app.', () => { - firebase.initializeApp({}, 'my-app'); - assert.equal(firebase.apps.length, 1); - firebase.initializeApp({}); - assert.equal(firebase.apps.length, 2); - }); + // Initial service registration happens on first invocation + assert.equal(registrations, 0); - it('Can get app via firebase namespace.', () => { - firebase.initializeApp({}); - }); + // Verify service has been registered + (firebase as any).lazyService(); + assert.equal(registrations, 1); - it('Duplicate DEFAULT initialize is an error.', () => { - firebase.initializeApp({}); - assert.throws(() => { - firebase.initializeApp({}); - }, /\[DEFAULT\].*exists/i); - }); + // Service should only be created once + (firebase as any).lazyService(); + assert.equal(registrations, 1); - it('Duplicate named App initialize is an error.', () => { - firebase.initializeApp({}, 'abc'); - assert.throws(() => { - firebase.initializeApp({}, 'abc'); - }, /'abc'.*exists/i); - }); + // Service should only be created once... regardless of how you invoke the function + (firebase as any).lazyService(app1); + assert.equal(registrations, 1); - it('automaticDataCollectionEnabled is `false` by default', () => { - let app = firebase.initializeApp({}, 'my-app'); - assert.equal(app.automaticDataCollectionEnabled, false); - }); + // Service should already be defined for the second app + const app2 = firebase.initializeApp({}, 'second'); + assert.isDefined((app1 as any).lazyService); - it('automaticDataCollectionEnabled can be set via the config object', () => { - let app = firebase.initializeApp( - {}, - { automaticDataCollectionEnabled: true } - ); - assert.equal(app.automaticDataCollectionEnabled, true); - }); + // Service still should not have registered for the second app + assert.equal(registrations, 1); - it('Modifying options object does not change options.', () => { - let options = { opt: 'original', nested: { opt: 123 } }; - firebase.initializeApp(options); - options.opt = 'changed'; - options.nested.opt = 456; - assert.deepEqual(firebase.app().options, { - opt: 'original', - nested: { opt: 123 } + // Service should initialize once called + (app2 as any).lazyService(); + assert.equal(registrations, 2); }); - }); - it('Error to use app after it is deleted.', () => { - let app = firebase.initializeApp({}); - return app.delete().then(() => { - assert.throws(() => { - console.log(app.name); - }, /already.*deleted/); + it('Can lazy register App Hook', done => { + const events = ['create', 'delete']; + let hookEvents = 0; + const app = firebase.initializeApp({}); + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'lazyServiceWithHook', + (app: FirebaseApp) => { + return new TestService(app); + }, + undefined, + (event: string, app: FirebaseApp) => { + assert.equal(event, events[hookEvents]); + hookEvents += 1; + if (hookEvents === events.length) { + done(); + } + } + ); + // Ensure the hook is called synchronously + assert.equal(hookEvents, 1); + app.delete(); }); - }); - it('OK to create same-name app after it is deleted.', () => { - let app = firebase.initializeApp({}, 'app-name'); - return app.delete().then(() => { - let app2 = firebase.initializeApp({}, 'app-name'); - assert.ok(app !== app2, 'Expect new instance.'); - // But original app id still orphaned. - assert.throws(() => { - console.log(app.name); - }, /already.*deleted/); + it('Can register multiple instances of some services', () => { + // Register Multi Instance Service + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'multiInstance', + (...args) => { + const [app, , instanceIdentifier] = args; + return new TestService(app, instanceIdentifier); + }, + null, + null, + true + ); + firebase.initializeApp({}); + + // Capture a given service ref + const service = (firebase.app() as any).multiInstance(); + assert.strictEqual(service, (firebase.app() as any).multiInstance()); + + // Capture a custom instance service ref + const serviceIdentifier = 'custom instance identifier'; + const service2 = (firebase.app() as any).multiInstance(serviceIdentifier); + assert.strictEqual( + service2, + (firebase.app() as any).multiInstance(serviceIdentifier) + ); + + // Ensure that the two services **are not equal** + assert.notStrictEqual( + service.instanceIdentifier, + service2.instanceIdentifier, + '`instanceIdentifier` is not being set correctly' + ); + assert.notStrictEqual(service, service2); + assert.notStrictEqual( + (firebase.app() as any).multiInstance(), + (firebase.app() as any).multiInstance(serviceIdentifier) + ); }); - }); - it('OK to use Object.prototype member names as app name.', () => { - let app = firebase.initializeApp({}, 'toString'); - assert.equal(firebase.apps.length, 1); - assert.equal(app.name, 'toString'); - assert.strictEqual(firebase.app('toString'), app); - }); + it(`Should return the same instance of a service if a service doesn't support multi instance`, () => { + // Register Multi Instance Service + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'singleInstance', + (...args) => { + const [app, , instanceIdentifier] = args; + return new TestService(app, instanceIdentifier); + }, + null, + null, + false // <-- multi instance flag + ); + firebase.initializeApp({}); - it('Error to get uninitialized app using Object.prototype member name.', () => { - assert.throws(() => { - firebase.app('toString'); - }, /'toString'.*created/i); - }); + // Capture a given service ref + const serviceIdentifier = 'custom instance identifier'; + const service = (firebase.app() as any).singleInstance(); + const service2 = (firebase.app() as any).singleInstance( + serviceIdentifier + ); + + // Ensure that the two services **are equal** + assert.strictEqual( + service.instanceIdentifier, + service2.instanceIdentifier, + '`instanceIdentifier` is not being set correctly' + ); + assert.strictEqual(service, service2); + }); + it(`Should pass null to the factory method if using default instance`, () => { + // Register Multi Instance Service + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'testService', + (...args) => { + const [app, , instanceIdentifier] = args; + assert.isUndefined( + instanceIdentifier, + '`instanceIdentifier` is not `undefined`' + ); + return new TestService(app, instanceIdentifier); + } + ); + firebase.initializeApp({}); - it('Only calls createService on first use (per app).', () => { - let registrations = 0; - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => { - registrations += 1; - return new TestService(app); - } - ); - let app = firebase.initializeApp({}); - assert.equal(registrations, 0); - (firebase as any).test(); - assert.equal(registrations, 1); - (firebase as any).test(); - assert.equal(registrations, 1); - (firebase as any).test(app); - assert.equal(registrations, 1); - (app as any).test(); - assert.equal(registrations, 1); - - app = firebase.initializeApp({}, 'second'); - assert.equal(registrations, 1); - (app as any).test(); - assert.equal(registrations, 2); + // Capture a given service ref + const serviceIdentifier = 'custom instance identifier'; + const service = (firebase.app() as any).testService(); + }); + it(`Should extend INTERNAL per app instance`, () => { + let counter: number = 0; + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'test', + (app: FirebaseApp, extendApp: any) => { + const service = new TestService(app); + (service as any).token = 'tokenFor' + counter++; + extendApp({ + INTERNAL: { + getToken: () => { + return Promise.resolve({ + accessToken: (service as any).token + }); + } + } + }); + return service; + } + ); + // Initialize 2 apps and their corresponding services. + const app = firebase.initializeApp({}); + (app as any).test(); + const app2 = firebase.initializeApp({}, 'app2'); + (app2 as any).test(); + // Confirm extended INTERNAL getToken resolve with the corresponding + // service's value. + return (app as _FirebaseApp).INTERNAL.getToken() + .then(token => { + assert.equal('tokenFor0', token.accessToken); + return (app2 as _FirebaseApp).INTERNAL.getToken(); + }) + .then(token => { + assert.equal('tokenFor1', token.accessToken); + }); + }); }); +} - it('Can lazy load a service', () => { - let registrations = 0; +function executeFirebaseLiteTests() { + firebaseAppTests('Firebase App Lite Tests', createFirebaseNamespaceLite); - const app1 = firebase.initializeApp({}); - assert.isUndefined((app1 as any).lazyService); + describe('Firebase Lite Service Registration', () => { + let firebase: FirebaseNamespace; - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'lazyService', - (app: FirebaseApp) => { - registrations += 1; - return new TestService(app); - } - ); + beforeEach(() => { + firebase = createFirebaseNamespaceLite(); + }); - assert.isDefined((app1 as any).lazyService); + it('should allow Performance service to register', () => { + let app: FirebaseApp; + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'performance', + (app: FirebaseApp) => { + return new TestService(app); + } + ); + app = firebase.initializeApp({}); + const perf = (app as any).performance(); + assert.isTrue(perf instanceof TestService); + }); - // Initial service registration happens on first invocation - assert.equal(registrations, 0); + it('should NOT allow services other than Performance to register', () => { + assert.throws(() => { + (firebase as _FirebaseNamespace).INTERNAL.registerService( + 'test', + (app: FirebaseApp) => { + return new TestService(app); + } + ); + }); + }); + }); +} - // Verify service has been registered - (firebase as any).lazyService(); - assert.equal(registrations, 1); +function firebaseAppTests(testName, firebaseNamespaceFactory) { + describe(testName, () => { + let firebase: FirebaseNamespace; - // Service should only be created once - (firebase as any).lazyService(); - assert.equal(registrations, 1); + beforeEach(() => { + firebase = firebaseNamespaceFactory(); + }); - // Service should only be created once... regardless of how you invoke the function - (firebase as any).lazyService(app1); - assert.equal(registrations, 1); + it('No initial apps.', () => { + assert.equal(firebase.apps.length, 0); + }); - // Service should already be defined for the second app - const app2 = firebase.initializeApp({}, 'second'); - assert.isDefined((app1 as any).lazyService); + it('Can initialize DEFAULT App.', () => { + const app = firebase.initializeApp({}); + assert.equal(firebase.apps.length, 1); + assert.strictEqual(app, firebase.apps[0]); + assert.equal(app.name, '[DEFAULT]'); + assert.strictEqual(firebase.app(), app); + assert.strictEqual(firebase.app('[DEFAULT]'), app); + }); - // Service still should not have registered for the second app - assert.equal(registrations, 1); + it('Can get options of App.', () => { + const options = { test: 'option' }; + const app = firebase.initializeApp(options); + assert.deepEqual(app.options as any, options as any); + }); - // Service should initialize once called - (app2 as any).lazyService(); - assert.equal(registrations, 2); - }); + it('Can delete App.', () => { + const app = firebase.initializeApp({}); + assert.equal(firebase.apps.length, 1); + return app.delete().then(() => { + assert.equal(firebase.apps.length, 0); + }); + }); - it('Can lazy register App Hook', done => { - let events = ['create', 'delete']; - let hookEvents = 0; - const app = firebase.initializeApp({}); - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'lazyServiceWithHook', - (app: FirebaseApp) => { - return new TestService(app); - }, - undefined, - (event: string, app: FirebaseApp) => { - assert.equal(event, events[hookEvents]); - hookEvents += 1; - if (hookEvents === events.length) { - done(); - } - } - ); - // Ensure the hook is called synchronously - assert.equal(hookEvents, 1); - app.delete(); - }); + it('Can create named App.', () => { + const app = firebase.initializeApp({}, 'my-app'); + assert.equal(firebase.apps.length, 1); + assert.equal(app.name, 'my-app'); + assert.strictEqual(firebase.app('my-app'), app); + }); - it('Can register multiple instances of some services', () => { - // Register Multi Instance Service - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'multiInstance', - (...args) => { - const [app, , instanceIdentifier] = args; - return new TestService(app, instanceIdentifier); - }, - null, - null, - true - ); - firebase.initializeApp({}); - - // Capture a given service ref - const service = (firebase.app() as any).multiInstance(); - assert.strictEqual(service, (firebase.app() as any).multiInstance()); - - // Capture a custom instance service ref - const serviceIdentifier = 'custom instance identifier'; - const service2 = (firebase.app() as any).multiInstance(serviceIdentifier); - assert.strictEqual( - service2, - (firebase.app() as any).multiInstance(serviceIdentifier) - ); - - // Ensure that the two services **are not equal** - assert.notStrictEqual( - service.instanceIdentifier, - service2.instanceIdentifier, - '`instanceIdentifier` is not being set correctly' - ); - assert.notStrictEqual(service, service2); - assert.notStrictEqual( - (firebase.app() as any).multiInstance(), - (firebase.app() as any).multiInstance(serviceIdentifier) - ); - }); + it('Can create named App and DEFAULT app.', () => { + firebase.initializeApp({}, 'my-app'); + assert.equal(firebase.apps.length, 1); + firebase.initializeApp({}); + assert.equal(firebase.apps.length, 2); + }); - it(`Should return the same instance of a service if a service doesn't support multi instance`, () => { - // Register Multi Instance Service - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'singleInstance', - (...args) => { - const [app, , instanceIdentifier] = args; - return new TestService(app, instanceIdentifier); - }, - null, - null, - false // <-- multi instance flag - ); - firebase.initializeApp({}); - - // Capture a given service ref - const serviceIdentifier = 'custom instance identifier'; - const service = (firebase.app() as any).singleInstance(); - const service2 = (firebase.app() as any).singleInstance(serviceIdentifier); - - // Ensure that the two services **are equal** - assert.strictEqual( - service.instanceIdentifier, - service2.instanceIdentifier, - '`instanceIdentifier` is not being set correctly' - ); - assert.strictEqual(service, service2); - }); - it(`Should pass null to the factory method if using default instance`, () => { - // Register Multi Instance Service - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'testService', - (...args) => { - const [app, , instanceIdentifier] = args; - assert.isUndefined( - instanceIdentifier, - '`instanceIdentifier` is not `undefined`' - ); - return new TestService(app, instanceIdentifier); - } - ); - firebase.initializeApp({}); + it('Can get app via firebase namespace.', () => { + firebase.initializeApp({}); + }); - // Capture a given service ref - const serviceIdentifier = 'custom instance identifier'; - const service = (firebase.app() as any).testService(); - }); + it('Duplicate DEFAULT initialize is an error.', () => { + firebase.initializeApp({}); + assert.throws(() => { + firebase.initializeApp({}); + }, /\[DEFAULT\].*exists/i); + }); - it(`Should extend INTERNAL per app instance`, () => { - let counter: number = 0; - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp, extendApp: any) => { - const service = new TestService(app); - (service as any).token = 'tokenFor' + counter++; - extendApp({ - INTERNAL: { - getToken: () => { - return Promise.resolve({ - accessToken: (service as any).token - }); - } - } - }); - return service; - } - ); - // Initialize 2 apps and their corresponding services. - const app = firebase.initializeApp({}); - (app as any).test(); - const app2 = firebase.initializeApp({}, 'app2'); - (app2 as any).test(); - // Confirm extended INTERNAL getToken resolve with the corresponding - // service's value. - return (app as _FirebaseApp).INTERNAL.getToken() - .then(token => { - assert.equal('tokenFor0', token.accessToken); - return (app2 as _FirebaseApp).INTERNAL.getToken(); - }) - .then(token => { - assert.equal('tokenFor1', token.accessToken); + it('Duplicate named App initialize is an error.', () => { + firebase.initializeApp({}, 'abc'); + assert.throws(() => { + firebase.initializeApp({}, 'abc'); + }, /'abc'.*exists/i); + }); + + it('automaticDataCollectionEnabled is `false` by default', () => { + const app = firebase.initializeApp({}, 'my-app'); + assert.equal(app.automaticDataCollectionEnabled, false); + }); + + it('automaticDataCollectionEnabled can be set via the config object', () => { + const app = firebase.initializeApp( + {}, + { automaticDataCollectionEnabled: true } + ); + assert.equal(app.automaticDataCollectionEnabled, true); + }); + + it('Modifying options object does not change options.', () => { + const options = { opt: 'original', nested: { opt: 123 } }; + firebase.initializeApp(options); + options.opt = 'changed'; + options.nested.opt = 456; + assert.deepEqual(firebase.app().options, { + opt: 'original', + nested: { opt: 123 } }); - }); + }); - describe('Check for bad app names', () => { - let tests = ['', 123, false, null]; - for (let data of tests) { - it("where name == '" + data + "'", () => { + it('Error to use app after it is deleted.', () => { + const app = firebase.initializeApp({}); + return app.delete().then(() => { assert.throws(() => { - firebase.initializeApp({}, data as string); - }, /Illegal app name/i); + console.log(app.name); + }, /already.*deleted/); }); - } - }); - describe('Check for bad app names, passed as an object', () => { - let tests = ['', 123, false, null]; - for (const name of tests) { - it("where name == '" + name + "'", () => { + }); + + it('OK to create same-name app after it is deleted.', () => { + const app = firebase.initializeApp({}, 'app-name'); + return app.delete().then(() => { + const app2 = firebase.initializeApp({}, 'app-name'); + assert.ok(app !== app2, 'Expect new instance.'); + // But original app id still orphaned. assert.throws(() => { - firebase.initializeApp({}, { name: name as string }); - }, /Illegal app name/i); + console.log(app.name); + }, /already.*deleted/); }); - } + }); + + it('OK to use Object.prototype member names as app name.', () => { + const app = firebase.initializeApp({}, 'toString'); + assert.equal(firebase.apps.length, 1); + assert.equal(app.name, 'toString'); + assert.strictEqual(firebase.app('toString'), app); + }); + + it('Error to get uninitialized app using Object.prototype member name.', () => { + assert.throws(() => { + firebase.app('toString'); + }, /'toString'.*created/i); + }); + + describe('Check for bad app names', () => { + const tests = ['', 123, false, null]; + for (const data of tests) { + it("where name == '" + data + "'", () => { + assert.throws(() => { + firebase.initializeApp({}, data as string); + }, /Illegal app name/i); + }); + } + }); + describe('Check for bad app names, passed as an object', () => { + const tests = ['', 123, false, null]; + for (const name of tests) { + it("where name == '" + name + "'", () => { + assert.throws(() => { + firebase.initializeApp({}, { name: name as string }); + }, /Illegal app name/i); + }); + } + }); }); -}); +} class TestService implements FirebaseService { constructor(private app_: FirebaseApp, public instanceIdentifier?: string) {} diff --git a/packages/auth/demo/functions/package.json b/packages/auth/demo/functions/package.json index 9d945f5dbb8..d81bd6f9fec 100644 --- a/packages/auth/demo/functions/package.json +++ b/packages/auth/demo/functions/package.json @@ -4,7 +4,7 @@ "scripts": { "serve": "firebase serve --only functions", "shell": "firebase experimental:functions:shell", - "start": "npm run shell", + "start": "yarn shell", "deploy": "firebase deploy --only functions", "logs": "firebase functions:log" }, diff --git a/packages/auth/package.json b/packages/auth/package.json index 3483b3a5405..95413d06642 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -13,9 +13,9 @@ "build": "gulp", "demo": "./buildtools/run_demo.sh", "generate-test-files": "./buildtools/generate_test_files.sh", - "prepare": "npm run build", - "serve": "npm run build && npm run generate-test-files && gulp serve", - "test": "npm run generate-test-files && ./buildtools/run_tests.sh" + "prepare": "yarn build", + "serve": "yarn build && yarn generate-test-files && gulp serve", + "test": "yarn generate-test-files && ./buildtools/run_tests.sh" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/database/package.json b/packages/database/package.json index 742329e9283..75ffe2e9ae2 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -6,6 +6,7 @@ "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -17,7 +18,7 @@ "test:browser": "karma start --single-run", "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file src/nodePatches.ts --opts ../../config/mocha.node.opts", "test:emulator": "ts-node --compiler-options='{\"module\":\"commonjs\"}' ../../scripts/emulator-testing/database-test-runner.ts", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "peerDependencies": { diff --git a/packages/database/rollup.config.js b/packages/database/rollup.config.js index ede0a60bcce..365301f11e9 100644 --- a/packages/database/rollup.config.js +++ b/packages/database/rollup.config.js @@ -15,27 +15,31 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default [ +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ /** * Node.js Build */ { input: 'index.node.ts', output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, /** @@ -47,7 +51,35 @@ export default [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +const es2017Builds = [ + /** + * Browser Build + */ + { + 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]; diff --git a/packages/firebase/.gitignore b/packages/firebase/.gitignore index 8bda5ffc95e..228443ec909 100644 --- a/packages/firebase/.gitignore +++ b/packages/firebase/.gitignore @@ -1,4 +1,4 @@ /firebase*.js /firebase*.map /firebase*.gz -/firebase*.tgz +/firebase*.tgz \ No newline at end of file diff --git a/packages/firebase/index.d.ts b/packages/firebase/index.d.ts index f9cfc55660e..e8e828f915d 100644 --- a/packages/firebase/index.d.ts +++ b/packages/firebase/index.d.ts @@ -1053,6 +1053,30 @@ declare namespace firebase { * @webonly */ function functions(app?: firebase.app.App): firebase.functions.Functions; + + /** + * Gets the {@link firebase.performance.Performance `Performance`} service. + * + * `firebase.performance()` can be called with no arguments to access the default + * app's {@link firebase.performance.Performance `Performance`} service. + * The {@link firebase.performance.Performance `Performance`} service does not work with + * any other app. + * + * @webonly + * + * @example + * ```javascript + * // Get the Performance service for the default app + * const defaultPerformance = firebase.performance(); + * ``` + * + * @param app The app to create a performance service for. Performance Monitoring only works with + * the default app. + * If not passed, uses the default app. + */ + function performance( + app?: firebase.app.App + ): firebase.performance.Performance; } declare namespace firebase.app { @@ -1177,6 +1201,133 @@ declare namespace firebase.app { * @webonly */ functions(region?: string): firebase.functions.Functions; + /** + * Gets the {@link firebase.performance.Performance `Performance`} service for the + * current app. If the current app is not the default one, throws an error. + * + * @webonly + * + * @example + * ```javascript + * const perf = app.performance(); + * // The above is shorthand for: + * // const perf = firebase.performance(app); + * ``` + */ + performance(): firebase.performance.Performance; + } +} + +/** + * @webonly + */ +declare namespace firebase.performance { + /** + * The Firebase Performance Monitoring service interface. + * + * Do not call this constructor directly. Instead, use + * {@link firebase.performance `firebase.performance()`}. + */ + export interface Performance { + /** + * Creates an uninitialized instance of {@link firebase.performance.Trace `trace`} and returns + * it. + * + * @param traceName The name of the trace instance. + * @return The Trace instance. + */ + trace(traceName: string): Trace; + + /** + * Controls the logging of automatic traces and HTTP/S network monitoring. + */ + instrumentationEnabled: boolean; + /** + * Controls the logging of custom traces. + */ + dataCollectionEnabled: boolean; + } + + export interface Trace { + /** + * Starts the timing for the {@link firebase.performance.Trace `trace`} instance. + */ + start(): void; + /** + * Stops the timing of the {@link firebase.performance.Trace `trace`} instance and logs the + * data of the instance. + */ + stop(): void; + /** + * Records a {@link firebase.performance.Trace `trace`} from given parameters. This provides a + * direct way to use {@link firebase.performance.Trace `trace`} without a need to start/stop. + * This is useful for use cases in which the {@link firebase.performance.Trace `trace`} cannot + * directly be used (e.g. if the duration was captured before the Performance SDK was loaded). + * + * @param startTime Trace start time since epoch in millisec. + * @param duration The duraction of the trace in millisec. + * @param options An object which can optionally hold maps of custom metrics and + * custom attributes. + */ + record( + startTime: number, + duration: number, + options?: { + metrics?: { [key: string]: number }; + attributes?: { [key: string]: string }; + } + ): void; + /** + * Adds to the value of a custom metric. If a custom metric with the provided name does not + * exist, it creates one with that name and the value equal to the given number. + * + * @param metricName The name of the custom metric. + * @param num The number to be added to the value of the custom metric. If not provided, it + * uses a default value of one. + */ + incrementMetric(metricName: string, num?: number): void; + /** + * Sets the value of the specified custom metric to the given number regardless of whether + * a metric with that name already exists on the {@link firebase.performance.Trace `trace`} + * instance or not. + * + * @param metricName Name of the custom metric. + * @param num Value to of the custom metric. + */ + putMetric(metricName: string, num: number): void; + /** + * Returns the value of the custom metric by that name. If a custom metric with that name does + * not exist returns zero. + * + * @param metricName Name of the custom metric. + */ + getMetric(metricName: string): number; + /** + * Set a custom attribute of a {@link firebase.performance.Trace `trace`} to a certain value. + * + * @param attr Name of the custom attribute. + * @param value Value of the custom attribute. + */ + putAttribute(attr: string, value: string): void; + /** + * Retrieves the value that the custom attribute is set to. + * + * @param attr Name of the custom attribute. + */ + getAttribute(attr: string): string | undefined; + /** + * Removes the specified custom attribute from a {@link firebase.performance.Trace `trace`} + * instance. + * + * @param attr Name of the custom attribute. + */ + + removeAttribute(attr: string): void; + /** + * Returns a map of all custom attributes of a {@link firebase.performance.Trace `trace`} + * instance. + */ + getAttributes(): { [key: string]: string }; } } diff --git a/packages/firebase/package.json b/packages/firebase/package.json index fa8ea1786c3..62c230bdec4 100644 --- a/packages/firebase/package.json +++ b/packages/firebase/package.json @@ -11,7 +11,8 @@ "Firebase", "firebase", "realtime", - "storage" + "storage", + "performance" ], "files": [ "**/dist/", @@ -29,7 +30,7 @@ "scripts": { "build": "rollup -c", "dev": "rollup -c -w", - "prepare": "npm run build" + "prepare": "yarn build" }, "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", @@ -43,7 +44,8 @@ "@firebase/functions": "0.4.5", "@firebase/messaging": "0.3.18", "@firebase/polyfill": "0.3.12", - "@firebase/storage": "0.2.14" + "@firebase/storage": "0.2.14", + "@firebase/performance": "0.2.0" }, "devDependencies": { "git-rev-sync": "1.12.0", @@ -54,6 +56,7 @@ "rollup-plugin-sourcemaps": "0.4.2", "rollup-plugin-typescript2": "0.21.0", "rollup-plugin-uglify": "6.0.2", + "rollup-plugin-terser": "4.0.4", "typescript": "3.4.5" }, "typings": "index.d.ts", @@ -64,6 +67,7 @@ "firestore", "functions", "messaging", - "storage" + "storage", + "performance" ] } diff --git a/packages/firebase/performance/index.ts b/packages/firebase/performance/index.ts new file mode 100644 index 00000000000..c4ee4a094b7 --- /dev/null +++ b/packages/firebase/performance/index.ts @@ -0,0 +1,17 @@ +/** + * 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/performance'; diff --git a/packages/firebase/performance/package.json b/packages/firebase/performance/package.json new file mode 100644 index 00000000000..96795ebddd7 --- /dev/null +++ b/packages/firebase/performance/package.json @@ -0,0 +1,5 @@ +{ + "name": "firebase/performance", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js" +} \ No newline at end of file diff --git a/packages/firebase/rollup.config.js b/packages/firebase/rollup.config.js index 9a2c082531b..177ef1a6efb 100644 --- a/packages/firebase/rollup.config.js +++ b/packages/firebase/rollup.config.js @@ -19,17 +19,37 @@ import { resolve } from 'path'; import resolveModule from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; import sourcemaps from 'rollup-plugin-sourcemaps'; -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import { uglify } from 'rollup-plugin-uglify'; +import { terser } from 'rollup-plugin-terser'; import pkg from './package.json'; import appPkg from './app/package.json'; +import authPkg from './auth/package.json'; +import databasePkg from './database/package.json'; +import firestorePkg from './firestore/package.json'; +import functionsPkg from './functions/package.json'; +import messagingPkg from './messaging/package.json'; +import storagePkg from './storage/package.json'; +import performancePkg from './performance/package.json'; + +const pkgsByName = { + app: appPkg, + auth: authPkg, + database: databasePkg, + firestore: firestorePkg, + functions: functionsPkg, + messaging: messagingPkg, + storage: storagePkg, + performance: performancePkg +}; const plugins = [ sourcemaps(), resolveModule(), - typescript({ - typescript: require('typescript') + typescriptPlugin({ + typescript }), commonjs() ]; @@ -177,6 +197,57 @@ const completeBuilds = [ output: { file: pkg['react-native'], format: 'cjs', sourcemap: true }, plugins, external + }, + /** + * Performance script Build + */ + { + input: 'src/index.perf.ts', + output: { + file: 'firebase-performance-standalone.js', + format: 'umd', + sourcemap: true, + name: GLOBAL_NAME + }, + plugins: [ + sourcemaps(), + resolveModule({ + mainFields: ['lite', 'module', 'main'] + }), + typescriptPlugin({ + typescript + }), + commonjs(), + uglify() + ] + }, + /** + * Performance script Build in ES2017 + */ + { + input: 'src/index.perf.ts', + output: { + file: 'firebase-performance-standalone.es2017.js', + format: 'umd', + sourcemap: true, + name: GLOBAL_NAME + }, + plugins: [ + sourcemaps(), + resolveModule({ + mainFields: ['lite-esm2017', 'esm2017', 'module', 'main'] + }), + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }), + commonjs(), + terser() + ] } ]; diff --git a/packages/firebase/src/index.cdn.ts b/packages/firebase/src/index.cdn.ts index 86a778b4fab..a0732eaa5b6 100644 --- a/packages/firebase/src/index.cdn.ts +++ b/packages/firebase/src/index.cdn.ts @@ -34,5 +34,6 @@ import '../firestore'; import '../functions'; import '../messaging'; import '../storage'; +import '../performance'; export default firebase; diff --git a/packages/firebase/src/index.perf.ts b/packages/firebase/src/index.perf.ts new file mode 100644 index 00000000000..fa85bb88094 --- /dev/null +++ b/packages/firebase/src/index.perf.ts @@ -0,0 +1,21 @@ +/** + * @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 '@firebase/performance'; + +export default firebase; diff --git a/packages/firebase/src/index.ts b/packages/firebase/src/index.ts index f93ff4dc3a1..5e23bafa24f 100644 --- a/packages/firebase/src/index.ts +++ b/packages/firebase/src/index.ts @@ -44,5 +44,6 @@ import '../firestore'; import '../functions'; import '../messaging'; import '../storage'; +import '../performance'; export default firebase; diff --git a/packages/firestore/package.json b/packages/firestore/package.json index 3ae9bc357f4..0c8827950a3 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -17,11 +17,12 @@ "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file index.node.ts --opts ../../config/mocha.node.opts", "test:node:persistence": "USE_MOCK_PERSISTENCE=YES TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --require ts-node/register --file index.node.ts --require test/util/node_persistence.ts --opts ../../config/mocha.node.opts", "test:emulator": "ts-node ../../scripts/emulator-testing/firestore-test-runner.ts", - "prepare": "npm run build" + "prepare": "yarn build" }, "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "license": "Apache-2.0", "files": [ "dist" diff --git a/packages/firestore/rollup.config.js b/packages/firestore/rollup.config.js index 0c614747d4f..aec0cb69fad 100644 --- a/packages/firestore/rollup.config.js +++ b/packages/firestore/rollup.config.js @@ -15,23 +15,26 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; import replace from 'rollup-plugin-replace'; import copy from 'rollup-plugin-copy-assets'; +import typescript from 'typescript'; import pkg from './package.json'; -import { dirname, resolve } from 'path'; - -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default [ +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ /** * Node.js Build */ @@ -39,7 +42,7 @@ export default [ input: 'index.node.ts', output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], plugins: [ - ...plugins, + ...es5BuildPlugins, // Needed as we also use the *.proto files copy({ assets: ['./src/protos'] @@ -62,7 +65,39 @@ export default [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +const es2017Builds = [ + /** + * Browser Build + */ + { + 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]; diff --git a/packages/firestore/tools/console.build.js b/packages/firestore/tools/console.build.js index f9acb483004..f65b1c772db 100644 --- a/packages/firestore/tools/console.build.js +++ b/packages/firestore/tools/console.build.js @@ -20,7 +20,8 @@ * This file creates a build target for it. */ const rollup = require('rollup'); -const typescript = require('rollup-plugin-typescript2'); +const typescriptPlugin = require('rollup-plugin-typescript2'); +const typescript = require('typescript'); const resolve = require('rollup-plugin-node-resolve'); const uglify = require('rollup-plugin-uglify'); const fs = require('fs'); @@ -29,8 +30,8 @@ const fs_writeFile = util.promisify(fs.writeFile); const plugins = [ resolve(), - typescript({ - typescript: require('typescript') + typescriptPlugin({ + typescript }), uglify({ output: { diff --git a/packages/functions/package.json b/packages/functions/package.json index 6076df3a13c..d9bd6a75420 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -6,6 +6,7 @@ "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -17,7 +18,7 @@ "test:browser:debug": "karma start --browsers=Chrome --auto-watch", "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file index.node.ts --opts ../../config/mocha.node.opts", "test:emulator": "env FIREBASE_FUNCTIONS_EMULATOR_ORIGIN=http://localhost:5005 run-p test:node", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "peerDependencies": { diff --git a/packages/functions/rollup.config.js b/packages/functions/rollup.config.js index 5504a254b41..edf5a27b30a 100644 --- a/packages/functions/rollup.config.js +++ b/packages/functions/rollup.config.js @@ -15,20 +15,24 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default [ +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ /** * Browser Builds */ @@ -38,7 +42,7 @@ export default [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, /** @@ -47,7 +51,39 @@ export default [ { input: 'index.node.ts', output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], - plugins, + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +/** + * ES2017 Builds + */ +const es2017Builds = [ + { + /** + * Browser Build + */ + 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]; diff --git a/packages/installations-types/index.d.ts b/packages/installations-types/index.d.ts new file mode 100644 index 00000000000..c983b511a63 --- /dev/null +++ b/packages/installations-types/index.d.ts @@ -0,0 +1,24 @@ +/** + * @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'; + +export interface FirebaseInstallations { + getId(): Promise; + getToken(): Promise; + delete(): Promise; +} diff --git a/packages/installations-types/package.json b/packages/installations-types/package.json new file mode 100644 index 00000000000..3aa187ae0f3 --- /dev/null +++ b/packages/installations-types/package.json @@ -0,0 +1,27 @@ +{ + "name": "@firebase/installations-types", + "version": "0.1.0", + "description": "@firebase/installations Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc" + }, + "files": [ + "index.d.ts" + ], + "peerDependencies": { + "@firebase/app-types": "0.x" + }, + "repository": { + "directory": "packages/installations-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" + } +} diff --git a/packages/installations-types/tsconfig.json b/packages/installations-types/tsconfig.json new file mode 100644 index 00000000000..750b91d0e5a --- /dev/null +++ b/packages/installations-types/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../installations/tsconfig.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": ["dist"] +} diff --git a/packages/installations/karma.conf.js b/packages/installations/karma.conf.js new file mode 100644 index 00000000000..458e08e69f7 --- /dev/null +++ b/packages/installations/karma.conf.js @@ -0,0 +1,27 @@ +/** + * @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 karmaBase = require('../../config/karma.base'); + +module.exports = function(config) { + config.set({ + ...karmaBase, + files: ['src/**/*.test.ts'], + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + frameworks: ['mocha'] + }); +}; diff --git a/packages/installations/package.json b/packages/installations/package.json new file mode 100644 index 00000000000..da15cc769fc --- /dev/null +++ b/packages/installations/package.json @@ -0,0 +1,56 @@ +{ + "name": "@firebase/installations", + "version": "0.1.0", + "main": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "types": "dist/index.d.ts", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "build": "rollup -c", + "test": "yarn type-check && yarn test:karma && yarn lint", + "test:karma": "karma start --single-run", + "test:debug": "karma start --browsers=Chrome --auto-watch", + "type-check": "tsc -p . --noEmit", + "lint": "tslint --project .", + "lint:fix": "yarn lint --fix && prettier --write 'src/**/*.ts'", + "serve": "yarn serve:build && yarn serve:host", + "serve:build": "rollup -c test-app/rollup.config.js", + "serve:host": "http-server -c-1 test-app", + "prepare": "yarn build" + }, + "devDependencies": { + "@types/chai": "4.1.7", + "@types/chai-as-promised": "7.1.0", + "@types/mocha": "5.2.6", + "@types/sinon": "7.0.11", + "@types/sinon-chai": "3.2.2", + "chai": "4.2.0", + "chai-as-promised": "7.1.1", + "http-server": "0.11.1", + "mocha": "6.1.4", + "rollup": "1.10.1", + "rollup-plugin-commonjs": "9.3.4", + "rollup-plugin-node-resolve": "4.2.3", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.21.0", + "rollup-plugin-uglify": "6.0.2", + "sinon": "7.3.2", + "sinon-chai": "3.3.0", + "tslint": "5.16.0", + "tslint-config-prettier": "1.18.0", + "tslint-no-unused-expression-chai": "0.1.4", + "tslint-plugin-prettier": "2.0.1", + "typescript": "3.4.5" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + }, + "dependencies": { + "@firebase/installations-types": "0.1.0", + "@firebase/util": "0.x", + "idb": "3.0.2" + } +} diff --git a/packages/installations/rollup.config.js b/packages/installations/rollup.config.js new file mode 100644 index 00000000000..1b8a6ba5047 --- /dev/null +++ b/packages/installations/rollup.config.js @@ -0,0 +1,77 @@ +/** + * @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 typescriptPlugin from 'rollup-plugin-typescript2'; +import replace from 'rollup-plugin-replace'; +import pkg from './package.json'; +import typescript from 'typescript'; + +const deps = Object.keys({ ...pkg.peerDependencies, ...pkg.dependencies }); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ typescript }), + replace({ + __VERSION__: pkg.version + }) +]; + +const es5Builds = [ + { + input: 'src/index.ts', + output: [ + { file: pkg.main, 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' + } + } + }), + replace({ + __VERSION__: pkg.version + }) +]; + +const es2017Builds = [ + { + input: 'src/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]; diff --git a/packages/installations/src/api/common.ts b/packages/installations/src/api/common.ts new file mode 100644 index 00000000000..c5c95468a98 --- /dev/null +++ b/packages/installations/src/api/common.ts @@ -0,0 +1,91 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; +import { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/app-config'; +import { + CompletedAuthToken, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION +} from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +export function getInstallationsEndpoint({ projectId }: AppConfig): string { + return `${INSTALLATIONS_API_URL}/projects/${projectId}/installations`; +} + +export function extractAuthTokenInfoFromResponse( + response: GenerateAuthTokenResponse +): CompletedAuthToken { + return { + token: response.token, + requestStatus: RequestStatus.COMPLETED, + expiresIn: getExpiresInFromResponseExpiresIn(response.expiresIn), + creationTime: Date.now() + }; +} + +export async function getErrorFromResponse( + requestName: string, + response: Response +): Promise { + const responseJson = await response.json(); + const errorData = responseJson.error; + return ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName, + serverCode: errorData.code, + serverMessage: errorData.message, + serverStatus: errorData.status + }); +} + +export function getHeaders({ apiKey }: AppConfig): Headers { + return new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': apiKey + }); +} + +export function getHeadersWithAuth( + appConfig: AppConfig, + { refreshToken }: RegisteredInstallationEntry +): Headers { + const headers = getHeaders(appConfig); + headers.append('Authorization', getAuthorizationHeader(refreshToken)); + return headers; +} + +export interface ErrorData { + code: number; + message: string; + status: string; +} + +function getExpiresInFromResponseExpiresIn(responseExpiresIn: string): number { + // This works because the server will never respond with fractions of a second. + return Number(responseExpiresIn.replace('s', '000')); +} + +function getAuthorizationHeader(refreshToken: string): string { + return `${INTERNAL_AUTH_VERSION} ${refreshToken}`; +} diff --git a/packages/installations/src/api/create-installation.test.ts b/packages/installations/src/api/create-installation.test.ts new file mode 100644 index 00000000000..18c2bb4a6f4 --- /dev/null +++ b/packages/installations/src/api/create-installation.test.ts @@ -0,0 +1,100 @@ +/** + * @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 { SinonStub, stub } from 'sinon'; +import { CreateInstallationResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/app-config'; +import { + InProgressInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeAppConfig } from '../testing/get-fake-app'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION, + PACKAGE_VERSION +} from '../util/constants'; +import { createInstallation } from './create-installation'; + +const FID = 'defenders-of-the-faith'; + +describe('api', () => { + let appConfig: AppConfig; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let inProgressInstallationEntry: InProgressInstallationEntry; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + + inProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() + }; + + const response: CreateInstallationResponse = { + refreshToken: 'refreshToken', + authToken: { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + expiresIn: '604800s' + } + }; + + fetchSpy = stub(self, 'fetch').resolves( + new Response(JSON.stringify(response)) + ); + }); + + it('registers a pending InstallationEntry', async () => { + const registeredInstallationEntry = await createInstallation( + appConfig, + inProgressInstallationEntry + ); + expect(registeredInstallationEntry.registrationStatus).to.equal( + RequestStatus.COMPLETED + ); + }); + + it('calls the createInstallation server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + 'x-goog-api-key': 'apiKey' + }); + const expectedBody = { + fid: FID, + authVersion: INTERNAL_AUTH_VERSION, + appId: appConfig.appId, + sdkVersion: PACKAGE_VERSION + }; + const expectedRequest: RequestInit = { + method: 'POST', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations`; + + await createInstallation(appConfig, inProgressInstallationEntry); + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); +}); diff --git a/packages/installations/src/api/create-installation.ts b/packages/installations/src/api/create-installation.ts new file mode 100644 index 00000000000..90bf8122275 --- /dev/null +++ b/packages/installations/src/api/create-installation.ts @@ -0,0 +1,66 @@ +/** + * @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 { CreateInstallationResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/app-config'; +import { + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { INTERNAL_AUTH_VERSION, PACKAGE_VERSION } from '../util/constants'; +import { + extractAuthTokenInfoFromResponse, + getErrorFromResponse, + getHeaders, + getInstallationsEndpoint +} from './common'; + +export async function createInstallation( + appConfig: AppConfig, + { fid }: InProgressInstallationEntry +): Promise { + const endpoint = getInstallationsEndpoint(appConfig); + + const headers = getHeaders(appConfig); + const body = { + fid, + authVersion: INTERNAL_AUTH_VERSION, + appId: appConfig.appId, + sdkVersion: PACKAGE_VERSION + }; + + const request: RequestInit = { + method: 'POST', + headers, + body: JSON.stringify(body) + }; + + const response = await fetch(endpoint, request); + if (response.ok) { + const responseValue: CreateInstallationResponse = await response.json(); + const registeredInstallationEntry: RegisteredInstallationEntry = { + fid, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: responseValue.refreshToken, + authToken: extractAuthTokenInfoFromResponse(responseValue.authToken) + }; + return registeredInstallationEntry; + } else { + throw getErrorFromResponse('Create Installation', response); + } +} diff --git a/packages/installations/src/api/delete-installation.test.ts b/packages/installations/src/api/delete-installation.test.ts new file mode 100644 index 00000000000..cd61cbe6d0e --- /dev/null +++ b/packages/installations/src/api/delete-installation.test.ts @@ -0,0 +1,75 @@ +/** + * @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 { SinonStub, stub } from 'sinon'; +import { AppConfig } from '../interfaces/app-config'; +import { + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeAppConfig } from '../testing/get-fake-app'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION +} from '../util/constants'; +import { deleteInstallation } from './delete-installation'; + +const FID = 'defenders-of-the-faith'; + +describe('deleteInstallation', () => { + let appConfig: AppConfig; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let registeredInstallationEntry: RegisteredInstallationEntry; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + + registeredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + + fetchSpy = stub(self, 'fetch').resolves(new Response()); + }); + + it('calls the deleteInstallation server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `${INTERNAL_AUTH_VERSION} refreshToken`, + 'x-goog-api-key': 'apiKey' + }); + const expectedRequest: RequestInit = { + method: 'DELETE', + headers: expectedHeaders + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations/${FID}`; + + await deleteInstallation(appConfig, registeredInstallationEntry); + + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); +}); diff --git a/packages/installations/src/api/delete-installation.ts b/packages/installations/src/api/delete-installation.ts new file mode 100644 index 00000000000..4392f0bfc46 --- /dev/null +++ b/packages/installations/src/api/delete-installation.ts @@ -0,0 +1,49 @@ +/** + * @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 { AppConfig } from '../interfaces/app-config'; +import { RegisteredInstallationEntry } from '../interfaces/installation-entry'; +import { + getErrorFromResponse, + getHeadersWithAuth, + getInstallationsEndpoint +} from './common'; + +export async function deleteInstallation( + appConfig: AppConfig, + installationEntry: RegisteredInstallationEntry +): Promise { + const endpoint = getDeleteEndpoint(appConfig, installationEntry); + + const headers = getHeadersWithAuth(appConfig, installationEntry); + const request: RequestInit = { + method: 'DELETE', + headers + }; + + const response = await fetch(endpoint, request); + if (!response.ok) { + throw getErrorFromResponse('Delete Installation', response); + } +} + +function getDeleteEndpoint( + appConfig: AppConfig, + { fid }: RegisteredInstallationEntry +): string { + return `${getInstallationsEndpoint(appConfig)}/${fid}`; +} diff --git a/packages/installations/src/api/generate-auth-token.test.ts b/packages/installations/src/api/generate-auth-token.test.ts new file mode 100644 index 00000000000..1288761a7ec --- /dev/null +++ b/packages/installations/src/api/generate-auth-token.test.ts @@ -0,0 +1,100 @@ +/** + * @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 { SinonStub, stub } from 'sinon'; +import { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/app-config'; +import { + CompletedAuthToken, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { compareHeaders } from '../testing/compare-headers'; +import { getFakeAppConfig } from '../testing/get-fake-app'; +import '../testing/setup'; +import { + INSTALLATIONS_API_URL, + INTERNAL_AUTH_VERSION, + PACKAGE_VERSION +} from '../util/constants'; +import { generateAuthToken } from './generate-auth-token'; + +const FID = 'defenders-of-the-faith'; + +describe('generateAuthToken', () => { + let appConfig: AppConfig; + let fetchSpy: SinonStub<[RequestInfo, RequestInit?], Promise>; + let registeredInstallationEntry: RegisteredInstallationEntry; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + + registeredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + + const response: GenerateAuthTokenResponse = { + token: + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCeKKF2QT4fwpMeJf36POk6yJV_adQssw5c', + expiresIn: '604800s' + }; + + fetchSpy = stub(self, 'fetch').resolves( + new Response(JSON.stringify(response)) + ); + }); + + it('fetches a new Authentication Token', async () => { + const completedAuthToken: CompletedAuthToken = await generateAuthToken( + appConfig, + registeredInstallationEntry + ); + expect(completedAuthToken.requestStatus).to.equal(RequestStatus.COMPLETED); + }); + + it('calls the generateAuthToken server API with correct parameters', async () => { + const expectedHeaders = new Headers({ + 'Content-Type': 'application/json', + Accept: 'application/json', + Authorization: `${INTERNAL_AUTH_VERSION} refreshToken`, + 'x-goog-api-key': 'apiKey' + }); + const expectedBody = { + installation: { + sdkVersion: PACKAGE_VERSION + } + }; + const expectedRequest: RequestInit = { + method: 'POST', + headers: expectedHeaders, + body: JSON.stringify(expectedBody) + }; + const expectedEndpoint = `${INSTALLATIONS_API_URL}/projects/projectId/installations/${FID}/authTokens:generate`; + + await generateAuthToken(appConfig, registeredInstallationEntry); + + expect(fetchSpy).to.be.calledOnceWith(expectedEndpoint, expectedRequest); + const actualHeaders = fetchSpy.lastCall.lastArg.headers; + compareHeaders(expectedHeaders, actualHeaders); + }); +}); diff --git a/packages/installations/src/api/generate-auth-token.ts b/packages/installations/src/api/generate-auth-token.ts new file mode 100644 index 00000000000..1491768b0ab --- /dev/null +++ b/packages/installations/src/api/generate-auth-token.ts @@ -0,0 +1,68 @@ +/** + * @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 { GenerateAuthTokenResponse } from '../interfaces/api-response'; +import { AppConfig } from '../interfaces/app-config'; +import { + CompletedAuthToken, + RegisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { PACKAGE_VERSION } from '../util/constants'; +import { + extractAuthTokenInfoFromResponse, + getErrorFromResponse, + getHeadersWithAuth, + getInstallationsEndpoint +} from './common'; + +export async function generateAuthToken( + appConfig: AppConfig, + installationEntry: RegisteredInstallationEntry +): Promise { + const endpoint = getGenerateAuthTokenEndpoint(appConfig, installationEntry); + + const headers = getHeadersWithAuth(appConfig, installationEntry); + const body = { + installation: { + sdkVersion: PACKAGE_VERSION + } + }; + + const request: RequestInit = { + method: 'POST', + headers, + body: JSON.stringify(body) + }; + + const response = await fetch(endpoint, request); + if (response.ok) { + const responseValue: GenerateAuthTokenResponse = await response.json(); + const completedAuthToken: CompletedAuthToken = extractAuthTokenInfoFromResponse( + responseValue + ); + return completedAuthToken; + } else { + throw getErrorFromResponse('Generate Auth Token', response); + } +} + +function getGenerateAuthTokenEndpoint( + appConfig: AppConfig, + { fid }: RegisteredInstallationEntry +): string { + return `${getInstallationsEndpoint(appConfig)}/${fid}/authTokens:generate`; +} diff --git a/packages/installations/src/functions/delete-installation.test.ts b/packages/installations/src/functions/delete-installation.test.ts new file mode 100644 index 00000000000..2e2d0fab9e4 --- /dev/null +++ b/packages/installations/src/functions/delete-installation.test.ts @@ -0,0 +1,129 @@ +/** + * @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 { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import * as deleteInstallationModule from '../api/delete-installation'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { get, set } from '../helpers/idb-manager'; +import { AppConfig } from '../interfaces/app-config'; +import { + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeApp } from '../testing/get-fake-app'; +import '../testing/setup'; +import { ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { deleteInstallation } from './delete-installation'; + +const FID = 'children-of-the-damned'; + +describe('deleteInstallation', () => { + let app: FirebaseApp; + let appConfig: AppConfig; + let deleteInstallationSpy: SinonStub< + [AppConfig, RegisteredInstallationEntry], + Promise + >; + + beforeEach(() => { + app = getFakeApp(); + appConfig = extractAppConfig(app); + + deleteInstallationSpy = stub( + deleteInstallationModule, + 'deleteInstallation' + ).callsFake( + () => sleep(50) // Request would take some time + ); + }); + + it('resolves without calling server API if there is no installation', async () => { + await expect(deleteInstallation(app)).to.eventually.be.fulfilled; + expect(deleteInstallationSpy).not.to.have.been.called; + }); + + it('deletes and resolves without calling server API if the installation is unregistered', async () => { + const entry: UnregisteredInstallationEntry = { + registrationStatus: RequestStatus.NOT_STARTED, + fid: FID + }; + await set(appConfig, entry); + + await expect(deleteInstallation(app)).to.eventually.be.fulfilled; + expect(deleteInstallationSpy).not.to.have.been.called; + await expect(get(appConfig)).to.eventually.be.undefined; + }); + + it('rejects without calling server API if the installation is pending', async () => { + const entry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() - 3 * 1000 + }; + await set(appConfig, entry); + + await expect(deleteInstallation(app)).to.eventually.be.rejectedWith( + ErrorCode.DELETE_PENDING_REGISTRATION + ); + expect(deleteInstallationSpy).not.to.have.been.called; + }); + + it('rejects without calling server API if the installation is registered and app is offline', async () => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: 'authToken', + expiresIn: 123456, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, entry); + stub(navigator, 'onLine').value(false); + + await expect(deleteInstallation(app)).to.eventually.be.rejectedWith( + ErrorCode.APP_OFFLINE + ); + expect(deleteInstallationSpy).not.to.have.been.called; + }); + + it('deletes and resolves after calling server API if the installation is registered', async () => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: 'authToken', + expiresIn: 123456, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, entry); + + await expect(deleteInstallation(app)).to.eventually.be.fulfilled; + expect(deleteInstallationSpy).to.have.been.calledOnceWith(appConfig, entry); + await expect(get(appConfig)).to.eventually.be.undefined; + }); +}); diff --git a/packages/installations/src/functions/delete-installation.ts b/packages/installations/src/functions/delete-installation.ts new file mode 100644 index 00000000000..bdbe21ecbd6 --- /dev/null +++ b/packages/installations/src/functions/delete-installation.ts @@ -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 { FirebaseApp } from '@firebase/app-types'; +import { deleteInstallation as deleteInstallationRequest } from '../api/delete-installation'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { remove, update } from '../helpers/idb-manager'; +import { + InProgressInstallationEntry, + InstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +export async function deleteInstallation(app: FirebaseApp): Promise { + const appConfig = extractAppConfig(app); + + const entry = await update( + appConfig, + ( + oldEntry?: InstallationEntry + ): + | InProgressInstallationEntry + | RegisteredInstallationEntry + | undefined => { + if ( + oldEntry && + oldEntry.registrationStatus === RequestStatus.NOT_STARTED + ) { + // Delete the unregistered entry without sending a deleteInstallation request. + return undefined; + } + return oldEntry; + } + ); + + if (entry) { + if (entry.registrationStatus === RequestStatus.IN_PROGRESS) { + // Can't delete while trying to register. + throw ERROR_FACTORY.create(ErrorCode.DELETE_PENDING_REGISTRATION); + } else if (entry.registrationStatus === RequestStatus.COMPLETED) { + if (!navigator.onLine) { + throw ERROR_FACTORY.create(ErrorCode.APP_OFFLINE); + } else { + await deleteInstallationRequest(appConfig, entry); + await remove(appConfig); + } + } + } +} diff --git a/packages/installations/src/functions/get-id.test.ts b/packages/installations/src/functions/get-id.test.ts new file mode 100644 index 00000000000..7c7ae97c6e3 --- /dev/null +++ b/packages/installations/src/functions/get-id.test.ts @@ -0,0 +1,53 @@ +/** + * @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 { SinonStub, stub } from 'sinon'; +import * as getInstallationEntryModule from '../helpers/get-installation-entry'; +import { AppConfig } from '../interfaces/app-config'; +import { RequestStatus } from '../interfaces/installation-entry'; +import { getFakeApp } from '../testing/get-fake-app'; +import '../testing/setup'; +import { getId } from './get-id'; + +const FID = 'children-of-the-damned'; + +describe('getId', () => { + let getInstallationEntrySpy: SinonStub< + [AppConfig], + Promise + >; + + beforeEach(() => { + getInstallationEntrySpy = stub( + getInstallationEntryModule, + 'getInstallationEntry' + ).resolves({ + installationEntry: { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + } + }); + }); + + it('returns the FID in InstallationEntry returned by getInstallationEntry', async () => { + const firebaseApp = getFakeApp(); + const fid = await getId(firebaseApp); + expect(fid).to.equal(FID); + expect(getInstallationEntrySpy).to.be.calledOnce; + }); +}); diff --git a/packages/installations/src/functions/get-id.ts b/packages/installations/src/functions/get-id.ts new file mode 100644 index 00000000000..d5e2cf2bc24 --- /dev/null +++ b/packages/installations/src/functions/get-id.ts @@ -0,0 +1,32 @@ +/** + * @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 { extractAppConfig } from '../helpers/extract-app-config'; +import { getInstallationEntry } from '../helpers/get-installation-entry'; + +export async function getId(app: FirebaseApp): Promise { + const appConfig = extractAppConfig(app); + const { installationEntry, registrationPromise } = await getInstallationEntry( + appConfig + ); + if (registrationPromise) { + // Suppress registration errors as they are not a problem for getId. + registrationPromise.catch(() => {}); + } + return installationEntry.fid; +} diff --git a/packages/installations/src/functions/get-token.test.ts b/packages/installations/src/functions/get-token.test.ts new file mode 100644 index 00000000000..7214473fe1a --- /dev/null +++ b/packages/installations/src/functions/get-token.test.ts @@ -0,0 +1,450 @@ +/** + * @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 { expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import * as createInstallationModule from '../api/create-installation'; +import * as generateAuthTokenModule from '../api/generate-auth-token'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { clear, get, set } from '../helpers/idb-manager'; +import { AppConfig } from '../interfaces/app-config'; +import { + CompletedAuthToken, + InProgressInstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeApp } from '../testing/get-fake-app'; +import '../testing/setup'; +import { TOKEN_EXPIRATION_BUFFER } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { getToken } from './get-token'; + +const FID = 'dont-talk-to-strangers'; +const AUTH_TOKEN = 'authTokenFromServer'; +const ONE_WEEK_MS = 7 * 24 * 60 * 60 * 1000; + +/** + * A map of different states of the database and a function that creates the + * said state. + */ +const setupInstallationEntryMap: Map< + string, + (appConfig: AppConfig) => Promise +> = new Map([ + [ + 'existing and valid auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + set(appConfig, entry); + } + ], + [ + 'expired auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - 2 * ONE_WEEK_MS + } + }; + set(appConfig, entry); + } + ], + [ + 'pending auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.IN_PROGRESS, + requestTime: Date.now() - 3 * 1000 + } + }; + + set(appConfig, entry); + + // Finish pending request in 10 ms + sleep(50).then(() => { + const updatedEntry: RegisteredInstallationEntry = { + ...entry, + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + set(appConfig, updatedEntry); + }); + } + ], + [ + 'no auth token', + async (appConfig: AppConfig) => { + const entry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + set(appConfig, entry); + } + ], + [ + 'pending fid registration', + async (appConfig: AppConfig) => { + const entry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() - 3 * 1000 + }; + + set(appConfig, entry); + + // Finish pending request in 10 ms + sleep(50).then(async () => { + const updatedEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + set(appConfig, updatedEntry); + }); + } + ], + [ + 'unregistered fid', + async (appConfig: AppConfig) => { + const entry: UnregisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }; + + set(appConfig, entry); + } + ] +]); + +describe('getToken', () => { + let app: FirebaseApp; + let appConfig: AppConfig; + let createInstallationSpy: SinonStub< + [AppConfig, InProgressInstallationEntry], + Promise + >; + let generateAuthTokenSpy: SinonStub< + [AppConfig, RegisteredInstallationEntry], + Promise + >; + + beforeEach(() => { + app = getFakeApp(); + appConfig = extractAppConfig(app); + + createInstallationSpy = stub( + createInstallationModule, + 'createInstallation' + ).callsFake(async (_, installationEntry) => { + await sleep(50); // Request would take some time + const result: RegisteredInstallationEntry = { + fid: installationEntry.fid, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + return result; + }); + generateAuthTokenSpy = stub( + generateAuthTokenModule, + 'generateAuthToken' + ).callsFake(async () => { + await sleep(50); // Request would take some time + const result: CompletedAuthToken = { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + }; + return result; + }); + }); + + afterEach(async () => { + // Clear the database after each test. + await clear(); + }); + + describe('basic functionality', () => { + for (const [title, setup] of setupInstallationEntryMap.entries()) { + describe(`when ${title} in the DB`, () => { + beforeEach(() => { + setup(appConfig); + }); + + it('resolves with an auth token', async () => { + const token = await getToken(app); + expect(token).to.equal(AUTH_TOKEN); + }); + + it('saves the token in the DB', async () => { + const token = await getToken(app); + const installationEntry = await get( + appConfig + ); + expect(installationEntry).not.to.be.undefined; + expect(installationEntry!.registrationStatus).to.equal( + RequestStatus.COMPLETED + ); + expect(installationEntry!.authToken.requestStatus).to.equal( + RequestStatus.COMPLETED + ); + expect( + (installationEntry!.authToken as CompletedAuthToken).token + ).to.equal(token); + }); + + it('returns the same token on subsequent calls', async () => { + const token1 = await getToken(app); + const token2 = await getToken(app); + expect(token1).to.equal(token2); + }); + }); + } + }); + + describe('when there is no FID in the DB', () => { + it('gets the token by registering a new FID', async () => { + await getToken(app); + expect(createInstallationSpy).to.be.called; + expect(generateAuthTokenSpy).not.to.be.called; + }); + + it('does not register a new FID on subsequent calls', async () => { + await getToken(app); + await getToken(app); + expect(createInstallationSpy).to.be.calledOnce; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(app)).to.eventually.be.rejected; + }); + }); + + describe('when there is a FID in the DB, but no auth token', () => { + let installationEntry: RegisteredInstallationEntry; + + beforeEach(async () => { + installationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.NOT_STARTED + } + }; + await set(appConfig, installationEntry); + }); + + it('gets the token by calling generateAuthToken', async () => { + await getToken(app); + expect(generateAuthTokenSpy).to.be.called; + expect(createInstallationSpy).not.to.be.called; + }); + + it('does not call generateAuthToken on subsequent calls', async () => { + await getToken(app); + await getToken(app); + expect(generateAuthTokenSpy).to.be.calledOnce; + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(app)).to.eventually.be.rejected; + }); + + describe('and the server returns an error', () => { + it('removes the FID from the DB if the server returns a 401 response', async () => { + generateAuthTokenSpy.callsFake(async () => { + await sleep(50); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 401, + serverStatus: 'UNAUTHENTICATED', + serverMessage: 'Invalid Authentication.' + }); + }); + + await expect(getToken(app)).to.eventually.be.rejected; + await expect(get(appConfig)).to.eventually.be.undefined; + }); + + it('removes the FID from the DB if the server returns a 404 response', async () => { + generateAuthTokenSpy.callsFake(async () => { + await sleep(50); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 404, + serverStatus: 'NOT_FOUND', + serverMessage: 'FID not found.' + }); + }); + + await expect(getToken(app)).to.eventually.be.rejected; + await expect(get(appConfig)).to.eventually.be.undefined; + }); + + it('does not remove the FID from the DB if the server returns any other response', async () => { + generateAuthTokenSpy.callsFake(async () => { + await sleep(50); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Generate Auth Token', + serverCode: 500, + serverStatus: 'INTERNAL', + serverMessage: 'Internal server error.' + }); + }); + + await expect(getToken(app)).to.eventually.be.rejected; + await expect(get(appConfig)).to.eventually.deep.equal( + installationEntry + ); + }); + }); + }); + + describe('when there is a registered auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() + } + }; + await set(appConfig, installationEntry); + }); + + it('does not call any server APIs', async () => { + await getToken(app); + expect(createInstallationSpy).not.to.be.called; + expect(generateAuthTokenSpy).not.to.be.called; + }); + + it('works even if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const token = await getToken(app); + expect(token).to.equal(AUTH_TOKEN); + }); + }); + + describe('when there is an auth token that is about to expire in the DB', () => { + const DB_AUTH_TOKEN = 'authTokenFromDB'; + + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: DB_AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - ONE_WEEK_MS + TOKEN_EXPIRATION_BUFFER + 10 + } + }; + await set(appConfig, installationEntry); + }); + + it('returns a different token after expiration', async () => { + const token1 = await getToken(app); + expect(token1).to.equal(DB_AUTH_TOKEN); + + // Wait until token expiration + await sleep(100); + + const token2 = await getToken(app); + expect(token2).to.equal(AUTH_TOKEN); + + expect(token1).not.to.equal(token2); + }); + }); + + describe('when there is an expired auth token in the DB', () => { + beforeEach(async () => { + const installationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + token: AUTH_TOKEN, + expiresIn: ONE_WEEK_MS, + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now() - 2 * ONE_WEEK_MS + } + }; + await set(appConfig, installationEntry); + }); + + it('throws if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + await expect(getToken(app)).to.eventually.be.rejected; + }); + }); +}); diff --git a/packages/installations/src/functions/get-token.ts b/packages/installations/src/functions/get-token.ts new file mode 100644 index 00000000000..3d7b90e45ae --- /dev/null +++ b/packages/installations/src/functions/get-token.ts @@ -0,0 +1,226 @@ +/** + * @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 { generateAuthToken } from '../api/generate-auth-token'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { getInstallationEntry } from '../helpers/get-installation-entry'; +import { remove, set, update } from '../helpers/idb-manager'; +import { AppConfig } from '../interfaces/app-config'; +import { + AuthToken, + CompletedAuthToken, + InProgressAuthToken, + InstallationEntry, + RegisteredInstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { PENDING_TIMEOUT_MS, TOKEN_EXPIRATION_BUFFER } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode, isServerError } from '../util/errors'; +import { sleep } from '../util/sleep'; + +export async function getToken(app: FirebaseApp): Promise { + const appConfig = extractAppConfig(app); + + await completeInstallationRegistration(appConfig); + + // At this point we either have a Registered Installation in the DB, or we've + // already thrown an error. + return fetchAuthToken(appConfig); +} + +async function completeInstallationRegistration( + appConfig: AppConfig +): Promise { + const { installationEntry, registrationPromise } = await getInstallationEntry( + appConfig + ); + + if (registrationPromise) { + // A createInstallation request is in progress. Wait until it finishes. + await registrationPromise; + } else if (installationEntry.registrationStatus !== RequestStatus.COMPLETED) { + // Installation ID can't be registered. + throw ERROR_FACTORY.create(ErrorCode.CREATE_INSTALLATION_FAILED); + } +} + +async function fetchAuthToken(appConfig: AppConfig): Promise { + let tokenPromise: Promise | undefined; + const entry = await update( + appConfig, + (oldEntry?: InstallationEntry): RegisteredInstallationEntry => { + if (!isEntryRegistered(oldEntry)) { + throw ERROR_FACTORY.create(ErrorCode.NOT_REGISTERED); + } + + const oldAuthToken = oldEntry.authToken; + if (isAuthTokenValid(oldAuthToken)) { + // There is a valid token in the DB. + return oldEntry; + } else if (oldAuthToken.requestStatus === RequestStatus.IN_PROGRESS) { + // There already is a token request in progress. + tokenPromise = waitUntilAuthTokenRequest(appConfig); + return oldEntry; + } else { + // No token or token expired. + if (!navigator.onLine) { + throw ERROR_FACTORY.create(ErrorCode.APP_OFFLINE); + } + + const inProgressEntry = makeAuthTokenRequestInProgressEntry(oldEntry); + tokenPromise = fetchAuthTokenFromServer(appConfig, inProgressEntry); + return inProgressEntry; + } + } + ); + + const authToken: CompletedAuthToken = tokenPromise + ? await tokenPromise + : (entry.authToken as CompletedAuthToken); + return authToken.token; +} + +/** + * Call only if FID is registered and Auth Token request is in progress. + */ +async function waitUntilAuthTokenRequest( + appConfig: AppConfig +): Promise { + // Unfortunately, there is no way of reliably observing when a value in + // IndexedDB changes (yet, see https://github.com/WICG/indexed-db-observers), + // so we need to poll. + + let entry = await updateAuthTokenRequest(appConfig); + while (entry.authToken.requestStatus === RequestStatus.IN_PROGRESS) { + // generateAuthToken still in progress. + await sleep(100); + + entry = await updateAuthTokenRequest(appConfig); + } + + const authToken = entry.authToken; + if (authToken.requestStatus === RequestStatus.NOT_STARTED) { + throw ERROR_FACTORY.create(ErrorCode.GENERATE_TOKEN_FAILED); + } else { + return authToken; + } +} + +/** + * Called only if there is a GenerateAuthToken request in progress. + * + * Updates the InstallationEntry in the DB based on the status of the + * GenerateAuthToken request. + * + * Returns the updated InstallationEntry. + */ +function updateAuthTokenRequest( + appConfig: AppConfig +): Promise { + return update( + appConfig, + (oldEntry?: InstallationEntry): RegisteredInstallationEntry => { + if (!isEntryRegistered(oldEntry)) { + throw ERROR_FACTORY.create(ErrorCode.NOT_REGISTERED); + } + + const oldAuthToken = oldEntry.authToken; + if (hasAuthTokenRequestTimedOut(oldAuthToken)) { + return { + ...oldEntry, + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + } + + return oldEntry; + } + ); +} + +async function fetchAuthTokenFromServer( + appConfig: AppConfig, + installationEntry: RegisteredInstallationEntry +): Promise { + try { + const authToken = await generateAuthToken(appConfig, installationEntry); + const updatedInstallationEntry: RegisteredInstallationEntry = { + ...installationEntry, + authToken + }; + await set(appConfig, updatedInstallationEntry); + return authToken; + } catch (e) { + if (isServerError(e) && (e.serverCode === 401 || e.serverCode === 404)) { + // Server returned a "FID not found" or a "Invalid authentication" error. + // Generate a new ID next time. + await remove(appConfig); + } else { + const updatedInstallationEntry: RegisteredInstallationEntry = { + ...installationEntry, + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + await set(appConfig, updatedInstallationEntry); + } + throw e; + } +} + +function isEntryRegistered( + installationEntry: InstallationEntry | undefined +): installationEntry is RegisteredInstallationEntry { + return ( + installationEntry !== undefined && + installationEntry.registrationStatus === RequestStatus.COMPLETED + ); +} + +function isAuthTokenValid(authToken: AuthToken): boolean { + return ( + authToken.requestStatus === RequestStatus.COMPLETED && + !isAuthTokenExpired(authToken) + ); +} + +function isAuthTokenExpired(authToken: CompletedAuthToken): boolean { + const now = Date.now(); + return ( + now < authToken.creationTime || + authToken.creationTime + authToken.expiresIn < now + TOKEN_EXPIRATION_BUFFER + ); +} + +/** Returns an updated InstallationEntry with an InProgressAuthToken. */ +function makeAuthTokenRequestInProgressEntry( + oldEntry: RegisteredInstallationEntry +): RegisteredInstallationEntry { + const inProgressAuthToken: InProgressAuthToken = { + requestStatus: RequestStatus.IN_PROGRESS, + requestTime: Date.now() + }; + return { + ...oldEntry, + authToken: inProgressAuthToken + }; +} + +function hasAuthTokenRequestTimedOut(authToken: AuthToken): boolean { + return ( + authToken.requestStatus === RequestStatus.IN_PROGRESS && + authToken.requestTime + PENDING_TIMEOUT_MS < Date.now() + ); +} diff --git a/packages/installations/src/functions/index.ts b/packages/installations/src/functions/index.ts new file mode 100644 index 00000000000..6c592c786a3 --- /dev/null +++ b/packages/installations/src/functions/index.ts @@ -0,0 +1,20 @@ +/** + * @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 { getId } from './get-id'; +export { getToken } from './get-token'; +export { deleteInstallation } from './delete-installation'; diff --git a/packages/installations/src/helpers/buffer-to-base64-url-safe.test.ts b/packages/installations/src/helpers/buffer-to-base64-url-safe.test.ts new file mode 100644 index 00000000000..556e723fa98 --- /dev/null +++ b/packages/installations/src/helpers/buffer-to-base64-url-safe.test.ts @@ -0,0 +1,37 @@ +/** + * @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 '../testing/setup'; +import { bufferToBase64UrlSafe } from './buffer-to-base64-url-safe'; + +const TYPED_ARRAY_REPRESENTATION = new TextEncoder().encode('hello world'); +const BASE_64_REPRESENTATION = btoa('hello world'); + +describe('bufferToBase64', () => { + it('returns a base64 representation of a Uint8Array', () => { + expect(bufferToBase64UrlSafe(TYPED_ARRAY_REPRESENTATION)).to.equal( + BASE_64_REPRESENTATION + ); + }); + + it('returns a base64 representation of an ArrayBuffer', () => { + expect(bufferToBase64UrlSafe(TYPED_ARRAY_REPRESENTATION.buffer)).to.equal( + BASE_64_REPRESENTATION + ); + }); +}); diff --git a/packages/installations/src/helpers/buffer-to-base64-url-safe.ts b/packages/installations/src/helpers/buffer-to-base64-url-safe.ts new file mode 100644 index 00000000000..6128685a571 --- /dev/null +++ b/packages/installations/src/helpers/buffer-to-base64-url-safe.ts @@ -0,0 +1,24 @@ +/** + * @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 function bufferToBase64UrlSafe( + buffer: ArrayBuffer | Uint8Array +): string { + const array = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer); + const b64 = btoa(String.fromCharCode(...array)); + return b64.replace(/\+/g, '-').replace(/\//g, '_'); +} diff --git a/packages/installations/src/helpers/extract-app-config.test.ts b/packages/installations/src/helpers/extract-app-config.test.ts new file mode 100644 index 00000000000..2d0e3074915 --- /dev/null +++ b/packages/installations/src/helpers/extract-app-config.test.ts @@ -0,0 +1,61 @@ +/** + * @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 { FirebaseError } from '@firebase/util'; +import { expect } from 'chai'; +import { AppConfig } from '../interfaces/app-config'; +import { getFakeApp } from '../testing/get-fake-app'; +import '../testing/setup'; +import { extractAppConfig } from './extract-app-config'; + +describe('extractAppConfig', () => { + it('returns AppConfig if the argument is a FirebaseApp object that includes an appId', () => { + const firebaseApp = getFakeApp(); + const expected: AppConfig = { + appName: 'appName', + apiKey: 'apiKey', + projectId: 'projectId', + appId: '1:777777777777:web:d93b5ca1475efe57' + }; + expect(extractAppConfig(firebaseApp)).to.deep.equal(expected); + }); + + it('throws if a necessary value is missing', () => { + // tslint:disable-next-line:no-any + expect(() => extractAppConfig(undefined as any)).to.throw(FirebaseError); + + let firebaseApp = getFakeApp(); + delete firebaseApp.name; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.projectId; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.apiKey; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + + firebaseApp = getFakeApp(); + delete firebaseApp.options.appId; + expect(() => extractAppConfig(firebaseApp)).to.throw(FirebaseError); + }); +}); diff --git a/packages/installations/src/helpers/extract-app-config.ts b/packages/installations/src/helpers/extract-app-config.ts new file mode 100644 index 00000000000..c1370f74461 --- /dev/null +++ b/packages/installations/src/helpers/extract-app-config.ts @@ -0,0 +1,35 @@ +/** + * @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 { AppConfig } from '../interfaces/app-config'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; + +export function extractAppConfig(app: FirebaseApp): AppConfig { + if (!app || !app.options) { + throw ERROR_FACTORY.create(ErrorCode.MISSING_APP_CONFIG_VALUES); + } + + const appName = app.name; + const { projectId, apiKey, appId } = app.options; + + if (!appName || !projectId || !apiKey || !appId) { + throw ERROR_FACTORY.create(ErrorCode.MISSING_APP_CONFIG_VALUES); + } + + return { appName, projectId, apiKey, appId }; +} diff --git a/packages/installations/src/helpers/generate-fid.test.ts b/packages/installations/src/helpers/generate-fid.test.ts new file mode 100644 index 00000000000..5bab8b246ee --- /dev/null +++ b/packages/installations/src/helpers/generate-fid.test.ts @@ -0,0 +1,120 @@ +/** + * @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 { stub } from 'sinon'; +import '../testing/setup'; +import { generateFid } from './generate-fid'; + +/** A few random values to generate a FID from. */ +// prettier-ignore +const MOCK_RANDOM_VALUES = [ + [14, 107, 44, 183, 190, 84, 253, 45, 219, 233, 43, 190, 240, 152, 195, 222, 237], + [184, 251, 91, 157, 125, 225, 209, 15, 116, 66, 46, 113, 194, 126, 16, 13, 226], + [197, 123, 13, 142, 239, 129, 252, 139, 156, 36, 219, 192, 153, 52, 182, 231, 177], + [69, 154, 197, 91, 156, 196, 125, 111, 3, 67, 212, 132, 169, 11, 14, 254, 125], + [193, 102, 58, 19, 244, 69, 36, 135, 170, 106, 98, 216, 246, 209, 24, 155, 149], + [252, 59, 222, 160, 82, 160, 82, 186, 14, 172, 196, 114, 146, 191, 196, 194, 146], + [64, 147, 153, 236, 225, 142, 235, 109, 184, 249, 174, 127, 33, 238, 227, 172, 111], + [129, 137, 136, 120, 248, 206, 253, 78, 159, 201, 216, 15, 246, 80, 118, 185, 211], + [117, 150, 2, 180, 116, 230, 45, 188, 183, 43, 152, 100, 50, 255, 101, 175, 190], + [156, 129, 30, 101, 58, 137, 217, 249, 12, 227, 235, 80, 248, 81, 191, 2, 5], + [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0], + [255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255], +] + +/** The FIDs that should be generated based on MOCK_RANDOM_VALUES. */ +const EXPECTED_FIDS = [ + 'fmsst75U_S3b6Su-8JjD3u', + 'ePtbnX3h0Q90Qi5xwn4QDe', + 'dXsNju-B_IucJNvAmTS257', + 'dZrFW5zEfW8DQ9SEqQsO_n', + 'cWY6E_RFJIeqamLY9tEYm5', + 'fDveoFKgUroOrMRykr_Ewp', + 'cJOZ7OGO6224-a5_Ie7jrG', + 'cYmIePjO_U6fydgP9lB2ud', + 'dZYCtHTmLby3K5hkMv9lr7', + 'fIEeZTqJ2fkM4-tQ-FG_Ag', + 'cAAAAAAAAAAAAAAAAAAAAA', + 'f_____________________' +]; + +const VALID_FID = /^[cdef][A-Za-z0-9_-]{21}$/; + +describe('generateFid', () => { + it('deterministically generates FIDs based on crypto.getRandomValues', () => { + let randomValueIndex = 0; + stub(crypto, 'getRandomValues').callsFake(array => { + if (!(array instanceof Uint8Array)) { + throw new Error('what'); + } + const values = MOCK_RANDOM_VALUES[randomValueIndex++]; + for (let i = 0; i < array.length; i++) { + array[i] = values[i]; + } + return array; + }); + + for (const expectedFid of EXPECTED_FIDS) { + expect(generateFid()).to.deep.equal(expectedFid); + } + }); + + it('generates valid FIDs', () => { + for (let i = 0; i < 1000; i++) { + const fid = generateFid(); + expect(VALID_FID.test(fid)).to.equal(true, `${fid} is not a valid FID`); + } + }); + + it('generates FIDs where each character is equally likely to appear in each location', () => { + const numTries = 200000; + + const charOccurrencesMapList: Array> = new Array(22); + for (let i = 0; i < charOccurrencesMapList.length; i++) { + charOccurrencesMapList[i] = new Map(); + } + + for (let i = 0; i < numTries; i++) { + const fid = generateFid(); + + Array.from(fid).forEach((char, location) => { + const map = charOccurrencesMapList[location]; + map.set(char, (map.get(char) || 0) + 1); + }); + } + + for (let i = 0; i < charOccurrencesMapList.length; i++) { + const map = charOccurrencesMapList[i]; + if (i === 0) { + // In the first location only 4 characters (c, d, e, f) are valid. + expect(map.size).to.equal(4); + } else { + // In locations other than the first, all 64 characters are valid. + expect(map.size).to.equal(64); + } + + Array.from(map.entries()).forEach(([_, occurrence]) => { + const expectedOccurrence = numTries / map.size; + + // 10% margin of error + expect(occurrence).to.be.above(expectedOccurrence * 0.9); + expect(occurrence).to.be.below(expectedOccurrence * 1.1); + }); + } + }).timeout(30000); +}); diff --git a/packages/installations/src/helpers/generate-fid.ts b/packages/installations/src/helpers/generate-fid.ts new file mode 100644 index 00000000000..c1af871cb5e --- /dev/null +++ b/packages/installations/src/helpers/generate-fid.ts @@ -0,0 +1,40 @@ +/** + * @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 { bufferToBase64UrlSafe } from './buffer-to-base64-url-safe'; + +/** Generates a new FID using random values from Web Crypto API. */ +export function generateFid(): string { + // A valid FID has exactly 22 base64 characters, which is 132 bits, or 16.5 + // bytes. our implementation generates a 17 byte array instead. + const fidByteArray = new Uint8Array(17); + crypto.getRandomValues(fidByteArray); + + // Replace the first 4 random bits with the constant FID header of 0b0111. + fidByteArray[0] = 0b01110000 + (fidByteArray[0] % 0b00010000); + + return encode(fidByteArray); +} + +/** Converts a FID Uint8Array to a base64 string representation. */ +function encode(fidByteArray: Uint8Array): string { + const b64String = bufferToBase64UrlSafe(fidByteArray); + + // Remove the 23rd character that was added because of the extra 4 bits at the + // end of our 17 byte array, and the '=' padding. + return b64String.substr(0, 22); +} diff --git a/packages/installations/src/helpers/get-installation-entry.test.ts b/packages/installations/src/helpers/get-installation-entry.test.ts new file mode 100644 index 00000000000..08bc8bc5690 --- /dev/null +++ b/packages/installations/src/helpers/get-installation-entry.test.ts @@ -0,0 +1,381 @@ +/** + * @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 { AssertionError, expect } from 'chai'; +import { SinonStub, stub } from 'sinon'; +import * as createInstallationModule from '../api/create-installation'; +import { AppConfig } from '../interfaces/app-config'; +import { + InProgressInstallationEntry, + InstallationEntry, + RegisteredInstallationEntry, + RequestStatus, + UnregisteredInstallationEntry +} from '../interfaces/installation-entry'; +import { getFakeAppConfig } from '../testing/get-fake-app'; +import '../testing/setup'; +import { ERROR_FACTORY, ErrorCode } from '../util/errors'; +import { sleep } from '../util/sleep'; +import * as fidGenerator from './generate-fid'; +import { getInstallationEntry } from './get-installation-entry'; +import { clear, get, set } from './idb-manager'; + +const FID = 'cry-of-the-black-birds'; + +describe('getInstallationEntry', () => { + let appConfig: AppConfig; + let createInstallationSpy: SinonStub< + [AppConfig, InProgressInstallationEntry], + Promise + >; + + beforeEach(() => { + appConfig = getFakeAppConfig(); + createInstallationSpy = stub( + createInstallationModule, + 'createInstallation' + ).callsFake( + async (_, installationEntry): Promise => { + await sleep(50); // Request would take some time + const registeredInstallationEntry: RegisteredInstallationEntry = { + fid: installationEntry.fid, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { + requestStatus: RequestStatus.COMPLETED, + creationTime: Date.now(), + token: 'token', + expiresIn: 1_000_000_000 + } + }; + return registeredInstallationEntry; + } + ); + }); + + afterEach(async () => { + // Wait until createInstallation completes + await sleep(100); + + // Clear the database after each test. + await clear(); + }); + + it('saves the InstallationEntry in the database before returning it', async () => { + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.be.undefined; + + const { installationEntry } = await getInstallationEntry(appConfig); + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.deep.equal(installationEntry); + }); + + it('saves the InstallationEntry in the database if app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.be.undefined; + + const { installationEntry } = await getInstallationEntry(appConfig); + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.deep.equal(installationEntry); + }); + + it('saves the InstallationEntry in the database when registration completes', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + expect(registrationPromise).to.be.an.instanceOf(Promise); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + await registrationPromise; + + const newDbEntry = await get(appConfig); + expect(newDbEntry!.registrationStatus).to.deep.equal( + RequestStatus.COMPLETED + ); + }); + + it('saves the InstallationEntry in the database when registration fails', async () => { + createInstallationSpy.callsFake(async () => { + await sleep(50); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Create Installation', + serverCode: 500, + serverStatus: 'INTERNAL', + serverMessage: 'Internal server error.' + }); + }); + + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + await expect(registrationPromise).to.eventually.be.rejected; + + const newDbEntry = await get(appConfig); + expect(newDbEntry!.registrationStatus).to.deep.equal( + RequestStatus.NOT_STARTED + ); + }); + + it('removes the InstallationEntry from the database when registration fails with 409', async () => { + createInstallationSpy.callsFake(async () => { + await sleep(50); // Request would take some time + throw ERROR_FACTORY.create(ErrorCode.REQUEST_FAILED, { + requestName: 'Create Installation', + serverCode: 409, + serverStatus: 'INVALID_ARGUMENT', + serverMessage: 'FID can not be used.' + }); + }); + + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + expect(installationEntry.registrationStatus).to.equal( + RequestStatus.IN_PROGRESS + ); + + const oldDbEntry = await get(appConfig); + expect(oldDbEntry).to.deep.equal(installationEntry); + + await expect(registrationPromise).to.eventually.be.rejected; + + const newDbEntry = await get(appConfig); + expect(newDbEntry).to.be.undefined; + }); + + it('returns the same FID on subsequent calls', async () => { + const { installationEntry: entry1 } = await getInstallationEntry(appConfig); + const { installationEntry: entry2 } = await getInstallationEntry(appConfig); + expect(entry1.fid).to.equal(entry2.fid); + }); + + describe('when there is no InstallationEntry in database', () => { + let generateInstallationEntrySpy: SinonStub<[], string>; + + beforeEach(() => { + generateInstallationEntrySpy = stub(fidGenerator, 'generateFid').returns( + FID + ); + }); + + it('returns a new pending InstallationEntry and triggers createInstallation', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + + if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { + throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); + } + + expect(registrationPromise).to.be.an.instanceOf(Promise); + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + + // https://github.com/chaijs/chai/issues/644 + registrationTime: installationEntry.registrationTime + }); + expect(generateInstallationEntrySpy).to.be.called; + expect(createInstallationSpy).to.be.called; + }); + + it('returns a new unregistered InstallationEntry if app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(generateInstallationEntrySpy).to.be.called; + expect(createInstallationSpy).not.to.be.called; + }); + + it('does not trigger createInstallation REST call on subsequent calls', async () => { + await getInstallationEntry(appConfig); + await getInstallationEntry(appConfig); + + expect(createInstallationSpy).to.be.calledOnce; + }); + + it('returns a registrationPromise on subsequent calls before initial promise resolves', async () => { + const { registrationPromise: promise1 } = await getInstallationEntry( + appConfig + ); + const { registrationPromise: promise2 } = await getInstallationEntry( + appConfig + ); + + expect(createInstallationSpy).to.be.calledOnce; + expect(promise1).to.be.an.instanceOf(Promise); + expect(promise2).to.be.an.instanceOf(Promise); + }); + + it('does not return a registrationPromise on subsequent calls after initial promise resolves', async () => { + const { registrationPromise: promise1 } = await getInstallationEntry( + appConfig + ); + expect(promise1).to.be.an.instanceOf(Promise); + + await promise1; + + const { registrationPromise: promise2 } = await getInstallationEntry( + appConfig + ); + expect(promise2).to.be.undefined; + + expect(createInstallationSpy).to.be.calledOnce; + }); + }); + + describe('when there is an unregistered InstallationEntry in the database', () => { + beforeEach(async () => { + const unregisteredInstallationEntry: UnregisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }; + await set(appConfig, unregisteredInstallationEntry); + }); + + it('returns a pending InstallationEntry and triggers createInstallation', async () => { + const { + installationEntry, + registrationPromise + } = await getInstallationEntry(appConfig); + + if (installationEntry.registrationStatus !== RequestStatus.IN_PROGRESS) { + throw new AssertionError('InstallationEntry is not IN_PROGRESS.'); + } + + expect(registrationPromise).to.be.an.instanceOf(Promise); + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + // https://github.com/chaijs/chai/issues/644 + registrationTime: installationEntry.registrationTime + }); + expect(createInstallationSpy).to.be.calledOnce; + }); + + it('returns the same InstallationEntry if the app is offline', async () => { + stub(navigator, 'onLine').value(false); + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(createInstallationSpy).not.to.be.called; + }); + }); + + describe('when there is a pending InstallationEntry in the database', () => { + beforeEach(async () => { + const inProgressInstallationEntry: InProgressInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_000_000 + }; + await set(appConfig, inProgressInstallationEntry); + }); + + it("returns the same InstallationEntry if the request hasn't timed out", async () => { + stub(Date, 'now').returns(1_001_000); // One second later + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_000_000 + }); + expect(createInstallationSpy).not.to.be.called; + }); + + it('returns a new pending InstallationEntry and triggers createInstallation if the request timed out', async () => { + stub(Date, 'now').returns(1_015_000); // Fifteen seconds later + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: 1_015_000 + }); + expect(createInstallationSpy).to.be.calledOnce; + }); + + it('returns a new unregistered InstallationEntry if the request timed out and the app is offline', async () => { + stub(navigator, 'onLine').value(false); + stub(Date, 'now').returns(1_015_000); // Fifteen seconds later + + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.NOT_STARTED + }); + expect(createInstallationSpy).not.to.be.called; + }); + }); + + describe('when there is a registered InstallationEntry in the database', () => { + beforeEach(async () => { + const registeredInstallationEntry: RegisteredInstallationEntry = { + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }; + await set(appConfig, registeredInstallationEntry); + }); + + it('returns the InstallationEntry from the database', async () => { + const { installationEntry } = await getInstallationEntry(appConfig); + + expect(installationEntry).to.deep.equal({ + fid: FID, + registrationStatus: RequestStatus.COMPLETED, + refreshToken: 'refreshToken', + authToken: { requestStatus: RequestStatus.NOT_STARTED } + }); + expect(createInstallationSpy).not.to.be.called; + }); + }); +}); diff --git a/packages/installations/src/helpers/get-installation-entry.ts b/packages/installations/src/helpers/get-installation-entry.ts new file mode 100644 index 00000000000..b552aff69d4 --- /dev/null +++ b/packages/installations/src/helpers/get-installation-entry.ts @@ -0,0 +1,206 @@ +/** + * @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 { createInstallation } from '../api/create-installation'; +import { AppConfig } from '../interfaces/app-config'; +import { + InProgressInstallationEntry, + InstallationEntry, + RequestStatus +} from '../interfaces/installation-entry'; +import { PENDING_TIMEOUT_MS } from '../util/constants'; +import { ERROR_FACTORY, ErrorCode, isServerError } from '../util/errors'; +import { sleep } from '../util/sleep'; +import { generateFid } from './generate-fid'; +import { remove, set, update } from './idb-manager'; + +export interface InstallationEntryWithRegistrationPromise { + installationEntry: InstallationEntry; + registrationPromise?: Promise; +} + +/** + * Updates and returns the InstallationEntry from the database. + * Also triggers a registration request if it is necessary and possible. + */ +export async function getInstallationEntry( + appConfig: AppConfig +): Promise { + let registrationPromise: Promise | undefined; + + return { + installationEntry: await update( + appConfig, + (oldEntry?: InstallationEntry): InstallationEntry => { + const installationEntry = updateOrCreateFid(oldEntry); + const entryWithPromise = triggerRegistrationIfNecessary( + appConfig, + installationEntry + ); + registrationPromise = entryWithPromise.registrationPromise; + return entryWithPromise.installationEntry; + } + ), + registrationPromise + }; +} + +function updateOrCreateFid( + oldEntry: InstallationEntry | undefined +): InstallationEntry { + const entry: InstallationEntry = oldEntry || { + fid: generateFid(), + registrationStatus: RequestStatus.NOT_STARTED + }; + + if (hasInstallationRequestTimedOut(entry)) { + return { + fid: entry.fid, + registrationStatus: RequestStatus.NOT_STARTED + }; + } + + return entry; +} + +/** + * If the Firebase Installation is not registered yet, this will trigger the registration + * and return an InProgressInstallationEntry. + */ +function triggerRegistrationIfNecessary( + appConfig: AppConfig, + installationEntry: InstallationEntry +): InstallationEntryWithRegistrationPromise { + if (installationEntry.registrationStatus === RequestStatus.NOT_STARTED) { + if (!navigator.onLine) { + // Registration required but app is offline. + const registrationPromiseWithError = Promise.reject( + ERROR_FACTORY.create(ErrorCode.APP_OFFLINE) + ); + return { + installationEntry, + registrationPromise: registrationPromiseWithError + }; + } + + // Try registering. Change status to IN_PROGRESS. + const inProgressEntry: InProgressInstallationEntry = { + fid: installationEntry.fid, + registrationStatus: RequestStatus.IN_PROGRESS, + registrationTime: Date.now() + }; + const registrationPromise = registerInstallation( + appConfig, + inProgressEntry + ); + return { installationEntry: inProgressEntry, registrationPromise }; + } else if ( + installationEntry.registrationStatus === RequestStatus.IN_PROGRESS + ) { + return { + installationEntry, + registrationPromise: waitUntilFidRegistration(appConfig) + }; + } else { + return { installationEntry }; + } +} + +/** This will be executed only once for each new Firebase Installation. */ +async function registerInstallation( + appConfig: AppConfig, + installationEntry: InProgressInstallationEntry +): Promise { + try { + const registeredInstallationEntry = await createInstallation( + appConfig, + installationEntry + ); + await set(appConfig, registeredInstallationEntry); + } catch (e) { + if (isServerError(e) && e.serverCode === 409) { + // Server returned a "FID can not be used" error. + // Generate a new ID next time. + await remove(appConfig); + } else { + // Registration failed. Set FID as not registered. + await set(appConfig, { + fid: installationEntry.fid, + registrationStatus: RequestStatus.NOT_STARTED + }); + } + throw e; + } +} + +/** Call if FID registration is pending. */ +async function waitUntilFidRegistration(appConfig: AppConfig): Promise { + // Unfortunately, there is no way of reliably observing when a value in + // IndexedDB changes (yet, see https://github.com/WICG/indexed-db-observers), + // so we need to poll. + + let entry: InstallationEntry = await updateInstallationRequest(appConfig); + while (entry.registrationStatus === RequestStatus.IN_PROGRESS) { + // createInstallation request still in progress. + await sleep(100); + + entry = await updateInstallationRequest(appConfig); + } + + if (entry.registrationStatus === RequestStatus.NOT_STARTED) { + throw ERROR_FACTORY.create(ErrorCode.CREATE_INSTALLATION_FAILED); + } +} + +/** + * Called only if there is a CreateInstallation request in progress. + * + * Updates the InstallationEntry in the DB based on the status of the + * CreateInstallation request. + * + * Returns the updated InstallationEntry. + */ +function updateInstallationRequest( + appConfig: AppConfig +): Promise { + return update( + appConfig, + (oldEntry?: InstallationEntry): InstallationEntry => { + if (!oldEntry) { + throw ERROR_FACTORY.create(ErrorCode.INSTALLATION_NOT_FOUND); + } + + if (hasInstallationRequestTimedOut(oldEntry)) { + return { + fid: oldEntry.fid, + registrationStatus: RequestStatus.NOT_STARTED + }; + } + + return oldEntry; + } + ); +} + +function hasInstallationRequestTimedOut( + installationEntry: InstallationEntry +): boolean { + return ( + installationEntry.registrationStatus === RequestStatus.IN_PROGRESS && + installationEntry.registrationTime + PENDING_TIMEOUT_MS < Date.now() + ); +} diff --git a/packages/installations/src/helpers/idb-manager.test.ts b/packages/installations/src/helpers/idb-manager.test.ts new file mode 100644 index 00000000000..8e8d53d91dd --- /dev/null +++ b/packages/installations/src/helpers/idb-manager.test.ts @@ -0,0 +1,140 @@ +/** + * @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 { AppConfig } from '../interfaces/app-config'; +import { getFakeAppConfig } from '../testing/get-fake-app'; +import '../testing/setup'; +import { clear, get, remove, set, update } from './idb-manager'; + +describe('idb manager', () => { + let appConfig1: AppConfig; + let appConfig2: AppConfig; + + beforeEach(() => { + appConfig1 = { ...getFakeAppConfig(), appName: 'appName1' }; + appConfig2 = { ...getFakeAppConfig(), appName: 'appName2' }; + }); + + afterEach(async () => { + // Clear the database after each test. + await clear(); + }); + + describe('get / set', () => { + it('sets a value and then gets the same value back', async () => { + await set(appConfig1, 'value'); + const value = await get(appConfig1); + expect(value).to.equal('value'); + }); + + it('gets undefined for a key that does not exist', async () => { + const value = await get(appConfig1); + expect(value).to.be.undefined; + }); + + it('sets and gets multiple values with different keys', async () => { + await set(appConfig1, 'value'); + await set(appConfig2, 'value2'); + expect(await get(appConfig1)).to.equal('value'); + expect(await get(appConfig2)).to.equal('value2'); + }); + + it('overwrites a value', async () => { + await set(appConfig1, 'value'); + await set(appConfig1, 'newValue'); + expect(await get(appConfig1)).to.equal('newValue'); + }); + }); + + describe('remove', () => { + it('deletes a key', async () => { + await set(appConfig1, 'value'); + await remove(appConfig1); + expect(await get(appConfig1)).to.be.undefined; + }); + + it('does not throw if key does not exist', async () => { + await remove(appConfig1); + expect(await get(appConfig1)).to.be.undefined; + }); + }); + + describe('clear', () => { + it('deletes all keys', async () => { + await set(appConfig1, 'value'); + await set(appConfig2, 'value2'); + await clear(); + expect(await get(appConfig1)).to.be.undefined; + expect(await get(appConfig2)).to.be.undefined; + }); + }); + + describe('update', () => { + it('gets and sets a value atomically, returns the new value', async () => { + let isGetCalled = false; + + await set(appConfig1, 'value'); + + const resultPromise = update(appConfig1, oldValue => { + // get is already called for the same key, but it will only complete + // after update transaction finishes, at which point it will return the + // new value. + expect(isGetCalled).to.be.true; + + expect(oldValue).to.equal('value'); + return 'newValue'; + }); + + // Called immediately after update, but before update completed. + const getPromise = get(appConfig1); + isGetCalled = true; + + // Update returns the new value + expect(await resultPromise).to.equal('newValue'); + + // If update weren't atomic, this would return the old value. + expect(await getPromise).to.equal('newValue'); + }); + + it('can change the type of the value', async () => { + let isGetCalled = false; + + await set(appConfig1, 'value'); + + const resultPromise = update(appConfig1, oldValue => { + // get is already called for the same key, but it will only complete + // after update transaction finishes, at which point it will return the + // new value. + expect(isGetCalled).to.be.true; + + expect(oldValue).to.equal('value'); + return 123; + }); + + // Called immediately after update, but before update completed. + const getPromise = get(appConfig1); + isGetCalled = true; + + // Update returns the new value + expect(await resultPromise).to.equal(123); + + // If update weren't atomic, this would return the old value. + expect(await getPromise).to.equal(123); + }); + }); +}); diff --git a/packages/installations/src/helpers/idb-manager.ts b/packages/installations/src/helpers/idb-manager.ts new file mode 100644 index 00000000000..42043f51f09 --- /dev/null +++ b/packages/installations/src/helpers/idb-manager.ts @@ -0,0 +1,114 @@ +/** + * @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 { DB, openDb } from 'idb'; +import { AppConfig } from '../interfaces/app-config'; + +const DATABASE_NAME = 'firebase-installations-database'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'firebase-installations-store'; + +const dbPromise: Promise = openDb( + DATABASE_NAME, + DATABASE_VERSION, + upgradeDB => { + // 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. + switch (upgradeDB.oldVersion) { + case 0: + upgradeDB.createObjectStore(OBJECT_STORE_NAME); + } + } +); + +/** Gets record(s) from the objectStore that match the given key. */ +export async function get( + appConfig: AppConfig +): Promise { + const key = getKey(appConfig); + const db = await dbPromise; + return db + .transaction(OBJECT_STORE_NAME) + .objectStore(OBJECT_STORE_NAME) + .get(key); +} + +/** Assigns or overwrites the record for the given key with the given value. */ +export async function set( + appConfig: AppConfig, + value: ValueType +): Promise { + const key = getKey(appConfig); + const db = await dbPromise; + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + tx.objectStore(OBJECT_STORE_NAME).put(value, key); + await tx.complete; + return value; +} + +/** Removes record(s) from the objectStore that match the given key. */ +export async function remove(appConfig: AppConfig): Promise { + const key = getKey(appConfig); + const db = await dbPromise; + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + tx.objectStore(OBJECT_STORE_NAME).delete(key); + return tx.complete; +} + +/** + * Atomically updates a record with the result of updateFn, which gets + * called with the current value. If newValue is undefined, the record is + * deleted instead. + * @return Updated value + */ +export async function update( + appConfig: AppConfig, + updateFn: (previousValue: OldType | undefined) => NewType +): Promise { + const key = getKey(appConfig); + const db = await dbPromise; + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + const store = tx.objectStore(OBJECT_STORE_NAME); + const oldValue = await store.get(key); + const newValue = updateFn(oldValue); + + if (newValue === oldValue) { + return newValue; + } + + if (newValue === undefined) { + store.delete(key); + } else { + store.put(newValue, key); + } + + await tx.complete; + return newValue; +} + +export async function clear(): Promise { + const db = await dbPromise; + const tx = db.transaction(OBJECT_STORE_NAME, 'readwrite'); + tx.objectStore(OBJECT_STORE_NAME).clear(); + return tx.complete; +} + +function getKey(appConfig: AppConfig): string { + return `${appConfig.appName}!${appConfig.appId}`; +} diff --git a/packages/installations/src/index.ts b/packages/installations/src/index.ts new file mode 100644 index 00000000000..e6b4fc1eae4 --- /dev/null +++ b/packages/installations/src/index.ts @@ -0,0 +1,58 @@ +/** + * @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, + FirebaseServiceFactory +} from '@firebase/app-types/private'; +import { FirebaseInstallations } from '@firebase/installations-types'; + +import { deleteInstallation, getId, getToken } from './functions'; +import { extractAppConfig } from './helpers/extract-app-config'; + +export function registerInstallations(instance: _FirebaseNamespace): void { + const installationsName = 'installations'; + + const factoryMethod: FirebaseServiceFactory = app => { + // Throws if app isn't configured properly. + extractAppConfig(app); + + return { + app, + getId: () => getId(app), + getToken: () => getToken(app), + delete: () => deleteInstallation(app) + }; + }; + + instance.INTERNAL.registerService(installationsName, factoryMethod); +} + +registerInstallations(firebase as _FirebaseNamespace); + +/** + * Define extension behavior of `registerInstallations` + */ +declare module '@firebase/app-types' { + interface FirebaseNamespace { + installations(app?: FirebaseApp): FirebaseInstallations; + } + interface FirebaseApp { + installations(): FirebaseInstallations; + } +} diff --git a/packages/installations/src/interfaces/api-response.ts b/packages/installations/src/interfaces/api-response.ts new file mode 100644 index 00000000000..177c3006b9e --- /dev/null +++ b/packages/installations/src/interfaces/api-response.ts @@ -0,0 +1,33 @@ +/** + * @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 CreateInstallationResponse { + readonly refreshToken: string; + readonly authToken: GenerateAuthTokenResponse; +} + +export interface GenerateAuthTokenResponse { + readonly token: string; + + /** + * Encoded as a string with the suffix 's' (indicating seconds), preceded by + * the number of seconds. + * + * Example: "604800s". + */ + readonly expiresIn: string; +} diff --git a/packages/installations/src/interfaces/app-config.ts b/packages/installations/src/interfaces/app-config.ts new file mode 100644 index 00000000000..052b30b12ca --- /dev/null +++ b/packages/installations/src/interfaces/app-config.ts @@ -0,0 +1,23 @@ +/** + * @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 AppConfig { + readonly appName: string; + readonly projectId: string; + readonly apiKey: string; + readonly appId: string; +} diff --git a/packages/installations/src/interfaces/installation-entry.ts b/packages/installations/src/interfaces/installation-entry.ts new file mode 100644 index 00000000000..591a763a5d2 --- /dev/null +++ b/packages/installations/src/interfaces/installation-entry.ts @@ -0,0 +1,110 @@ +/** + * @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. + */ + +/** Status of a server request. */ +export const enum RequestStatus { + NOT_STARTED, + IN_PROGRESS, + COMPLETED +} + +export interface NotStartedAuthToken { + readonly requestStatus: RequestStatus.NOT_STARTED; +} + +export interface InProgressAuthToken { + readonly requestStatus: RequestStatus.IN_PROGRESS; + + /** + * Unix timestamp when the current generateAuthRequest was initiated. + * Used for figuring out how long the request status has been IN_PROGRESS. + */ + readonly requestTime: number; +} + +export interface CompletedAuthToken { + readonly requestStatus: RequestStatus.COMPLETED; + + /** + * Firebase Installations Authentication Token. + * Only exists if requestStatus is COMPLETED. + */ + readonly token: string; + + /** + * Unix timestamp when Authentication Token was created. + * Only exists if requestStatus is COMPLETED. + */ + readonly creationTime: number; + + /** + * Authentication Token time to live duration in milliseconds. + * Only exists if requestStatus is COMPLETED. + */ + readonly expiresIn: number; +} + +export type AuthToken = + | NotStartedAuthToken + | InProgressAuthToken + | CompletedAuthToken; + +export interface UnregisteredInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.NOT_STARTED; + + /** Firebase Installation ID */ + readonly fid: string; +} + +export interface InProgressInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.IN_PROGRESS; + + /** + * Unix timestamp that shows the time when the current createInstallation + * request was initiated. + * Used for figuring out how long the registration status has been PENDING. + */ + readonly registrationTime: number; + + /** Firebase Installation ID */ + readonly fid: string; +} + +export interface RegisteredInstallationEntry { + /** Status of the Firebase Installation registration on the server. */ + readonly registrationStatus: RequestStatus.COMPLETED; + + /** Firebase Installation ID */ + readonly fid: string; + + /** + * Refresh Token returned from the server. + * Used for authenticating generateAuthToken requests. + */ + readonly refreshToken: string; + + /** Firebase Installation Authentication Token. */ + readonly authToken: AuthToken; +} + +/** Firebase Installation ID and related data in the database. */ +export type InstallationEntry = + | UnregisteredInstallationEntry + | InProgressInstallationEntry + | RegisteredInstallationEntry; diff --git a/packages/installations/src/testing/compare-headers.test.ts b/packages/installations/src/testing/compare-headers.test.ts new file mode 100644 index 00000000000..5971bba502f --- /dev/null +++ b/packages/installations/src/testing/compare-headers.test.ts @@ -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 { AssertionError, expect } from 'chai'; +import '../testing/setup'; +import { compareHeaders } from './compare-headers'; + +describe('compareHeaders', () => { + it("doesn't fail if headers contain the same entries", () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: '456' }); + compareHeaders(headers1, headers2); + }); + + it('fails if headers contain different keys', () => { + const headers1 = new Headers({ a: '123', b: '456', extraKey: '789' }); + const headers2 = new Headers({ a: '123', b: '456' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); + + it('fails if headers contain different values', () => { + const headers1 = new Headers({ a: '123', b: '456' }); + const headers2 = new Headers({ a: '123', b: 'differentValue' }); + expect(() => { + compareHeaders(headers1, headers2); + }).to.throw(AssertionError); + }); +}); diff --git a/packages/installations/src/testing/compare-headers.ts b/packages/installations/src/testing/compare-headers.ts new file mode 100644 index 00000000000..86330314a10 --- /dev/null +++ b/packages/installations/src/testing/compare-headers.ts @@ -0,0 +1,41 @@ +/** + * @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 { AssertionError, expect } from 'chai'; + +// Trick TS since it's set to target ES5. +declare class HeadersWithEntries extends Headers { + entries?(): Iterable<[string, string]>; +} + +// Chai doesn't check if Headers objects contain the same entries, +// so we need to do that manually. +export function compareHeaders( + expectedHeaders: HeadersWithEntries, + actualHeaders: HeadersWithEntries +): void { + if ( + expectedHeaders.entries === undefined || + actualHeaders.entries === undefined + ) { + throw new AssertionError('Headers object does not have entries method'); + } + + const expected = new Map(Array.from(expectedHeaders.entries())); + const actual = new Map(Array.from(actualHeaders.entries())); + expect(actual).to.deep.equal(expected); +} diff --git a/packages/installations/src/testing/get-fake-app.ts b/packages/installations/src/testing/get-fake-app.ts new file mode 100644 index 00000000000..ee73adaf8a6 --- /dev/null +++ b/packages/installations/src/testing/get-fake-app.ts @@ -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 { FirebaseApp } from '@firebase/app-types'; +import { extractAppConfig } from '../helpers/extract-app-config'; +import { AppConfig } from '../interfaces/app-config'; + +export function getFakeApp(): FirebaseApp { + return { + name: 'appName', + options: { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: 'messagingSenderId', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57' + }, + automaticDataCollectionEnabled: true, + delete: async () => {}, + // This won't be used in tests. + // tslint:disable-next-line:no-any + installations: null as any + }; +} + +export function getFakeAppConfig(): AppConfig { + return extractAppConfig(getFakeApp()); +} diff --git a/packages/installations/src/testing/setup.ts b/packages/installations/src/testing/setup.ts new file mode 100644 index 00000000000..df2c8edc2f4 --- /dev/null +++ b/packages/installations/src/testing/setup.ts @@ -0,0 +1,28 @@ +/** + * @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 chaiAsPromised from 'chai-as-promised'; +import { restore } from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +use(chaiAsPromised); +use(sinonChai); + +afterEach(() => { + restore(); +}); diff --git a/packages/installations/src/util/constants.ts b/packages/installations/src/util/constants.ts new file mode 100644 index 00000000000..5ffcba3cb36 --- /dev/null +++ b/packages/installations/src/util/constants.ts @@ -0,0 +1,29 @@ +/** + * @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 const PENDING_TIMEOUT_MS = 10000; + +export const PACKAGE_VERSION = 'w:__VERSION__'; // Will be replaced by Rollup +export const INTERNAL_AUTH_VERSION = 'FIS_v2'; + +export const INSTALLATIONS_API_URL = + 'https://firebaseinstallations.googleapis.com/v1'; + +export const TOKEN_EXPIRATION_BUFFER = 60 * 60 * 1000; // One hour + +export const SERVICE = 'installations'; +export const SERVICE_NAME = 'Installations'; diff --git a/packages/installations/src/util/errors.ts b/packages/installations/src/util/errors.ts new file mode 100644 index 00000000000..4fdc9e158eb --- /dev/null +++ b/packages/installations/src/util/errors.ts @@ -0,0 +1,66 @@ +/** + * @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'; +import { SERVICE, SERVICE_NAME } from './constants'; + +export const enum ErrorCode { + MISSING_APP_CONFIG_VALUES = 'missing-app-config-values', + CREATE_INSTALLATION_FAILED = 'create-installation-failed', + GENERATE_TOKEN_FAILED = 'generate-token-failed', + NOT_REGISTERED = 'not-registered', + INSTALLATION_NOT_FOUND = 'installation-not-found', + REQUEST_FAILED = 'request-failed', + APP_OFFLINE = 'app-offline', + DELETE_PENDING_REGISTRATION = 'delete-pending-registration' +} + +const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { + [ErrorCode.MISSING_APP_CONFIG_VALUES]: 'Missing App configuration values.', + [ErrorCode.CREATE_INSTALLATION_FAILED]: + 'Could not register Firebase Installation.', + [ErrorCode.GENERATE_TOKEN_FAILED]: 'Could not generate Auth Token.', + [ErrorCode.NOT_REGISTERED]: 'Firebase Installation is not registered.', + [ErrorCode.INSTALLATION_NOT_FOUND]: 'Firebase Installation not found.', + [ErrorCode.REQUEST_FAILED]: + '{$requestName} request failed with error "{$serverCode} {$serverStatus}: {$serverMessage}"', + [ErrorCode.APP_OFFLINE]: 'Could not process request. Application offline.', + [ErrorCode.DELETE_PENDING_REGISTRATION]: + "Can't delete installation while there is a pending registration request." +}; + +export const ERROR_FACTORY = new ErrorFactory( + SERVICE, + SERVICE_NAME, + ERROR_DESCRIPTION_MAP +); + +export interface ServerErrorData { + serverCode: number; + serverMessage: string; + serverStatus: string; +} + +export type ServerError = FirebaseError & ServerErrorData; + +/** Returns true if error is a FirebaseError that is based on an error from the server. */ +export function isServerError(error: unknown): error is ServerError { + return ( + error instanceof FirebaseError && + error.code.includes(ErrorCode.REQUEST_FAILED) + ); +} diff --git a/packages/installations/src/util/sleep.test.ts b/packages/installations/src/util/sleep.test.ts new file mode 100644 index 00000000000..be2abdcc1b9 --- /dev/null +++ b/packages/installations/src/util/sleep.test.ts @@ -0,0 +1,42 @@ +/** + * @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 { SinonFakeTimers, useFakeTimers } from 'sinon'; +import '../testing/setup'; +import { sleep } from './sleep'; + +describe('sleep', () => { + let clock: SinonFakeTimers; + + beforeEach(() => { + clock = useFakeTimers(); + }); + + afterEach(() => { + clock.restore(); + }); + + it('returns a promise that resolves after a given amount of time', async () => { + const sleepPromise = sleep(100); + expect(sleepPromise).not.to.be.fulfilled; + clock.tick(99); + expect(sleepPromise).not.to.be.fulfilled; + clock.tick(1); + expect(sleepPromise).to.be.fulfilled; + }); +}); diff --git a/packages/installations/src/util/sleep.ts b/packages/installations/src/util/sleep.ts new file mode 100644 index 00000000000..a815dec4aa6 --- /dev/null +++ b/packages/installations/src/util/sleep.ts @@ -0,0 +1,23 @@ +/** + * @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. + */ + +/** Returns a promise that resolves after given time passes. */ +export function sleep(ms: number): Promise { + return new Promise(resolve => { + setTimeout(resolve, ms); + }); +} diff --git a/packages/installations/test-app/.gitignore b/packages/installations/test-app/.gitignore new file mode 100644 index 00000000000..e706d63f780 --- /dev/null +++ b/packages/installations/test-app/.gitignore @@ -0,0 +1,2 @@ +sdk.js +sdk.js.map diff --git a/packages/installations/test-app/index.html b/packages/installations/test-app/index.html new file mode 100644 index 00000000000..4eb8d35e28a --- /dev/null +++ b/packages/installations/test-app/index.html @@ -0,0 +1,43 @@ + + + + + Test App + + + + +

+ + +

+

+ + +

+

+ + +

+

+ + +

+

+ + + + +

+

Requests

+
+

Database Contents

+
+ + + diff --git a/packages/installations/test-app/index.js b/packages/installations/test-app/index.js new file mode 100644 index 00000000000..f534f411aac --- /dev/null +++ b/packages/installations/test-app/index.js @@ -0,0 +1,113 @@ +/** + * @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 DATABASE_NAME = 'firebase-installations-database'; +const DATABASE_VERSION = 1; +const OBJECT_STORE_NAME = 'firebase-installations-store'; + +const requestLogs = []; +let db; + +window.indexedDB.open(DATABASE_NAME, DATABASE_VERSION).onsuccess = event => { + db = event.target.result; + setInterval(refreshDatabase, 1000); +}; + +function refreshDatabase() { + const request = db + .transaction(OBJECT_STORE_NAME, 'readwrite') + .objectStore(OBJECT_STORE_NAME) + .getAll(); + + request.onsuccess = () => { + const dbElement = getElement('database'); + dbElement.innerHTML = request.result + .map(v => `

${format(v)}

`) + .join(''); + }; +} + +function clearDb() { + const request = db + .transaction(OBJECT_STORE_NAME, 'readwrite') + .objectStore(OBJECT_STORE_NAME) + .clear(); + request.onsuccess = refreshDatabase; +} + +function getElement(id) { + const element = document.getElementById(id); + if (!element) { + throw new Error(`Element not found: ${id}`); + } + return element; +} + +function getInputValue(elementId) { + const element = getElement(elementId); + return element.value; +} + +function getId() { + printRequest('Get ID', FirebaseInstallations.getId(getApp())); +} + +function getToken() { + printRequest('Get Token', FirebaseInstallations.getToken(getApp())); +} + +function deleteInstallation() { + printRequest( + 'Delete Installation', + FirebaseInstallations.deleteInstallation(getApp()) + ); +} + +async function printRequest(requestInfo, promise) { + const requestsElement = getElement('requests'); + requestsElement.innerHTML = '

Loading...

' + requestLogs.join(''); + let result; + try { + const request = await promise; + result = request ? format(request) : 'Completed successfully'; + } catch (e) { + result = e.toString(); + } + requestLogs.unshift(`

${requestInfo}:
${result}

`); + requestsElement.innerHTML = requestLogs.join(''); +} + +function format(o) { + const escapedString = JSON.stringify(o, null, 2); + return `${escapedString}`; +} + +function getApp() { + const appName = getInputValue('appName'); + const projectId = getInputValue('projectId'); + const apiKey = getInputValue('apiKey'); + const appId = getInputValue('appId'); + return { + name: appName, + options: { projectId, apiKey, appId } + }; +} + +getElement('getId').onclick = getId; +getElement('getToken').onclick = getToken; +getElement('deleteInstallation').onclick = deleteInstallation; +getElement('clearDb').onclick = clearDb; diff --git a/packages/installations/test-app/rollup.config.js b/packages/installations/test-app/rollup.config.js new file mode 100644 index 00000000000..8052a3dc81e --- /dev/null +++ b/packages/installations/test-app/rollup.config.js @@ -0,0 +1,51 @@ +/** + * @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 typescriptPlugin from 'rollup-plugin-typescript2'; +import resolve from 'rollup-plugin-node-resolve'; +import commonjs from 'rollup-plugin-commonjs'; +import replace from 'rollup-plugin-replace'; +import { uglify } from 'rollup-plugin-uglify'; +import pkg from '../package.json'; +import typescript from 'typescript'; + +/** + * Creates an iife build to run with the Test App. + */ +export default [ + { + input: 'src/functions/index.ts', + output: { + name: 'FirebaseInstallations', + file: 'test-app/sdk.js', + format: 'iife', + sourcemap: true + }, + plugins: [ + typescriptPlugin({ + typescript, + tsconfigOverride: { compilerOptions: { declaration: false } } + }), + replace({ + __VERSION__: pkg.version + }), + resolve(), + commonjs(), + uglify() + ] + } +]; diff --git a/packages/installations/tsconfig.json b/packages/installations/tsconfig.json new file mode 100644 index 00000000000..98a202f1743 --- /dev/null +++ b/packages/installations/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "lib": ["es2017", "dom"], + "downlevelIteration": true, + + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src"] +} diff --git a/packages/installations/tslint.json b/packages/installations/tslint.json new file mode 100644 index 00000000000..bd06fc57e8f --- /dev/null +++ b/packages/installations/tslint.json @@ -0,0 +1,27 @@ +{ + "extends": [ + "tslint:latest", + "tslint-no-unused-expression-chai", + "tslint-config-prettier" + ], + "rulesDirectory": ["tslint-plugin-prettier"], + "rules": { + "prettier": true, + "interface-name": [true, "never-prefix"], + "member-access": [true, "no-public"], + "member-ordering": [true, { "order": "statics-first" }], + "no-any": true, + "no-empty": [true, "allow-empty-functions"], + "no-default-export": true, + "no-implicit-dependencies": [ + true, + ["chai", "chai-as-promised", "sinon", "sinon-chai"] + ], + "no-submodule-imports": false, + "no-unnecessary-type-assertion": true, + "object-literal-sort-keys": false, + "prefer-method-signature": true, + "typedef": [true, "call-signature"], + "unified-signatures": false + } +} diff --git a/packages/logger/package.json b/packages/logger/package.json index 30e11ccfc89..aa2b1071085 100644 --- a/packages/logger/package.json +++ b/packages/logger/package.json @@ -5,6 +5,7 @@ "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -15,7 +16,7 @@ "test:browser": "karma start --single-run", "test:browser:debug": "karma start --browsers Chrome --auto-watch", "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --opts ../../config/mocha.node.opts", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "devDependencies": { diff --git a/packages/logger/rollup.config.js b/packages/logger/rollup.config.js index 2c0bf20329f..bfc324e9977 100644 --- a/packages/logger/rollup.config.js +++ b/packages/logger/rollup.config.js @@ -15,12 +15,16 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript }) ]; @@ -28,12 +32,43 @@ const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default { - input: 'index.ts', - output: [ - { file: pkg.main, format: 'cjs', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true } - ], - plugins, - external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) -}; +const es5Builds = [ + { + input: 'index.ts', + output: [ + { file: pkg.main, 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' + } + } + }) +]; + +const es2017Builds = [ + { + 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]; diff --git a/packages/messaging/package.json b/packages/messaging/package.json index 9172961191d..244da3b46d8 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -5,6 +5,7 @@ "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -14,7 +15,7 @@ "test": "run-p test:karma type-check lint", "test:karma": "karma start --single-run", "test:debug": "karma start --browsers=Chrome --auto-watch", - "prepare": "npm run build", + "prepare": "yarn build", "type-check": "tsc --noEmit", "lint": "tslint -p .", "lint:fix": "yarn lint --fix" diff --git a/packages/messaging/rollup.config.js b/packages/messaging/rollup.config.js index 2c0bf20329f..dd72ab9c57b 100644 --- a/packages/messaging/rollup.config.js +++ b/packages/messaging/rollup.config.js @@ -15,25 +15,59 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default { - input: 'index.ts', - output: [ - { file: pkg.main, format: 'cjs', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true } - ], - plugins, - external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) -}; +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ + { + input: 'index.ts', + output: [ + { file: pkg.main, 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' + } + } + }) +]; + +const es2017Builds = [ + { + 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]; diff --git a/packages/messaging/test/constructor.test.ts b/packages/messaging/test/constructor.test.ts index 03af79f1b23..40712ed2b2d 100644 --- a/packages/messaging/test/constructor.test.ts +++ b/packages/messaging/test/constructor.test.ts @@ -15,6 +15,7 @@ * limitations under the License. */ +import { FirebaseError } from '@firebase/util'; import { assert } from 'chai'; import { SwController } from '../src/controllers/sw-controller'; @@ -27,41 +28,39 @@ import { describe } from './testing-utils/messaging-test-runner'; describe('Firebase Messaging > new *Controller()', () => { it('should handle bad input', () => { const badInputs = [ - makeFakeApp(), + makeFakeApp({ + messagingSenderId: undefined + } as any), makeFakeApp({ messagingSenderId: {} - }), + } as any), makeFakeApp({ messagingSenderId: [] - }), + } as any), makeFakeApp({ messagingSenderId: true - }), + } as any), makeFakeApp({ messagingSenderId: 1234567890 - }) + } as any) ]; badInputs.forEach(badInput => { - let caughtError; try { new WindowController(badInput); new SwController(badInput); - console.warn( - 'Bad Input should have thrown: ', - JSON.stringify(badInput) + assert.fail( + `Bad Input should have thrown: ${JSON.stringify(badInput)}` ); - } catch (err) { - caughtError = err; + } catch (e) { + assert.instanceOf(e, FirebaseError); + assert.equal('messaging/' + ErrorCode.BAD_SENDER_ID, e.code); } - assert.equal('messaging/' + ErrorCode.BAD_SENDER_ID, caughtError.code); }); }); it('should be able to handle good input', () => { - const app = makeFakeApp({ - messagingSenderId: '1234567890' - }); + const app = makeFakeApp(); new WindowController(app); new SwController(app); }); diff --git a/packages/messaging/test/testing-utils/make-fake-app.ts b/packages/messaging/test/testing-utils/make-fake-app.ts index d34d647a04a..800b785f7ad 100644 --- a/packages/messaging/test/testing-utils/make-fake-app.ts +++ b/packages/messaging/test/testing-utils/make-fake-app.ts @@ -15,14 +15,26 @@ * limitations under the License. */ -declare const window: { firebase: any }; +import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; -import { FirebaseApp } from '@firebase/app-types'; - -export function makeFakeApp(options: object = {}): FirebaseApp { - window.firebase = window.firebase || {}; - const app: any = {}; - app.INTERNAL = window.firebase.INTERNAL; - app.options = options; - return app; +export function makeFakeApp(options: FirebaseOptions = {}): FirebaseApp { + options = { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: '1234567890', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57', + ...options + }; + return { + name: 'appName', + options, + automaticDataCollectionEnabled: true, + delete: async () => {}, + // This won't be used in tests. + // tslint:disable-next-line:no-any + messaging: null as any + }; } diff --git a/packages/performance-types/index.d.ts b/packages/performance-types/index.d.ts new file mode 100644 index 00000000000..5227e5ef46e --- /dev/null +++ b/packages/performance-types/index.d.ts @@ -0,0 +1,110 @@ +/** + * 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. + */ + +export interface FirebasePerformance { + /** + * Creates an uninitialized instance of trace and returns it. + * + * @param traceName The name of trace instance. + * @return The trace instance. + */ + trace(traceName: string): PerformanceTrace; + + /** + * Controls the logging of automatic traces and HTTP/S network monitoring. + */ + instrumentationEnabled: boolean; + /** + * Controls the logging of custom traces. + */ + dataCollectionEnabled: boolean; +} + +export interface PerformanceTrace { + /** + * Starts the timing for the trace instance. + */ + start(): void; + /** + * Stops the timing of the trace instance and logs the data of the instance. + */ + stop(): void; + /** + * Records a trace from given parameters. This provides a direct way to use trace without a need to + * start/stop. This is useful for use cases in which the trace cannot directly be used + * (e.g. if the duration was captured before the Performance SDK was loaded). + * + * @param startTime trace start time since epoch in millisec. + * @param duration The duraction of the trace in millisec. + * @param options An object which can optionally hold maps of custom metrics and + * custom attributes. + */ + record( + startTime: number, + duration: number, + options?: { + metrics?: { [key: string]: number }; + attributes?: { [key: string]: string }; + } + ): void; + /** + * Adds to the value of a custom metric. If a custom metric with the provided name does not + * exist, it creates one with that name and the value equal to the given number. + * + * @param metricName The name of the custom metric. + * @param num The number to be added to the value of the custom metric. If not provided, it + * uses a default value of one. + */ + incrementMetric(metricName: string, num?: number): void; + /** + * Sets the value of the specified custom metric to the given number regardless of whether + * a metric with that name already exists on the trace instance or not. + * + * @param metricName Name of the custom metric. + * @param num Value to of the custom metric. + */ + putMetric(metricName: string, num: number): void; + /** + * Returns the value of the custom metric by that name. If a custom metric with that name does + * not exist will return zero. + * + * @param metricName Name of the custom metric. + */ + getMetric(metricName: string): number; + /** + * Set a custom attribute of a trace to a certain value. + * + * @param attr Name of the custom attribute. + * @param value Value of the custom attribute. + */ + putAttribute(attr: string, value: string): void; + /** + * Retrieves the value which a custom attribute is set to. + * + * @param attr Name of the custom attribute. + */ + getAttribute(attr: string): string | undefined; + /** + * Removes the specified custom attribute from a trace instance. + * + * @param attr Name of the custom attribute. + */ + removeAttribute(attr: string): void; + /** + * Returns a map of all custom attributes of a trace instance. + */ + getAttributes(): { [key: string]: string }; +} diff --git a/packages/performance-types/package.json b/packages/performance-types/package.json new file mode 100644 index 00000000000..a45a28df504 --- /dev/null +++ b/packages/performance-types/package.json @@ -0,0 +1,24 @@ +{ + "name": "@firebase/performance-types", + "private": true, + "version": "0.0.1", + "description": "@firebase/performance Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc" + }, + "files": [ + "index.d.ts" + ], + "devDependencies": { + "typescript": "3.4.5" + }, + "repository": { + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk/tree/master/packages/performance-types" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + } +} diff --git a/packages/performance-types/tsconfig.json b/packages/performance-types/tsconfig.json new file mode 100644 index 00000000000..09f747b4d46 --- /dev/null +++ b/packages/performance-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist" + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/performance/README.md b/packages/performance/README.md new file mode 100644 index 00000000000..5c83dbc51b7 --- /dev/null +++ b/packages/performance/README.md @@ -0,0 +1,5 @@ +# @firebase/performance + +This is the Firebase Performance component of the Firebase JS SDK. + +**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.** diff --git a/packages/performance/index.ts b/packages/performance/index.ts new file mode 100644 index 00000000000..4edd1a0508e --- /dev/null +++ b/packages/performance/index.ts @@ -0,0 +1,69 @@ +/** + * @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 { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; +import { + _FirebaseNamespace, + FirebaseServiceFactory +} from '@firebase/app-types/private'; +import { PerformanceController } from './src/controllers/perf'; +import { setupApi } from './src/services/api_service'; +import { SettingsService } from './src/services/settings_service'; +import { consoleLogger } from './src/utils/console_logger'; +import { ERROR_FACTORY, ErrorCode } from './src/utils/errors'; +import { FirebasePerformance } from '@firebase/performance-types'; + +const DEFAULT_ENTRY_NAME = '[DEFAULT]'; + +export function registerPerformance(instance: FirebaseNamespace): void { + const factoryMethod: FirebaseServiceFactory = (app: FirebaseApp) => { + if (app.name !== DEFAULT_ENTRY_NAME) { + throw ERROR_FACTORY.create(ErrorCode.FB_NOT_DEFAULT); + } + SettingsService.getInstance().firebaseAppInstance = app; + return new PerformanceController(app); + }; + + // Register performance with firebase-app. + const namespaceExports = {}; + (instance as _FirebaseNamespace).INTERNAL.registerService( + 'performance', + factoryMethod, + namespaceExports + ); +} + +if (window && fetch && Promise) { + setupApi(window); + registerPerformance(firebase); +} else { + consoleLogger.info( + 'Firebase Performance cannot start if browser does not support fetch and Promise.' + ); +} + +declare module '@firebase/app-types' { + interface FirebaseNamespace { + performance?: { + (app?: FirebaseApp): FirebasePerformance; + }; + } + interface FirebaseApp { + performance?(): FirebasePerformance; + } +} diff --git a/packages/performance/karma.conf.js b/packages/performance/karma.conf.js new file mode 100644 index 00000000000..90f901ba0bf --- /dev/null +++ b/packages/performance/karma.conf.js @@ -0,0 +1,29 @@ +/** + * 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. + */ + +const karmaBase = require('../../config/karma.base'); + +module.exports = function(config) { + config.set({ + ...karmaBase, + // files to load into karma + files: [`test/**/*`, 'src/**/*.test.ts'], + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); +}; diff --git a/packages/performance/package.json b/packages/performance/package.json new file mode 100644 index 00000000000..b8f05384272 --- /dev/null +++ b/packages/performance/package.json @@ -0,0 +1,63 @@ +{ + "name": "@firebase/performance", + "version": "0.2.0", + "description": "Firebase performance for web", + "author": "Firebase (https://firebase.google.com/)", + "main": "dist/index.cjs.js", + "browser": "dist/index.cjs.js", + "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", + "files": [ + "dist" + ], + "scripts": { + "build": "rollup -c", + "dev": "rollup -c -w", + "test": "run-p test:browser", + "test:browser": "karma start --single-run", + "test:debug": "karma start --browsers=Chrome --auto-watch", + "prepare": "yarn build", + "prettier": "prettier --write '{src,test}/**/*.{js,ts}'" + }, + "peerDependencies": { + "@firebase/app": "0.x", + "@firebase/app-types": "0.x" + }, + "dependencies": { + "@firebase/logger": "0.1.1", + "@firebase/installations": "0.1.0", + "@firebase/util": "0.2.13", + "@firebase/performance-types": "0.0.1", + "tslib": "1.9.3" + }, + "license": "Apache-2.0", + "devDependencies": { + "@types/chai": "4.1.7", + "@types/chai-as-promised": "7.1.0", + "@types/mocha": "5.2.6", + "@types/sinon": "7.0.11", + "@types/sinon-chai": "3.2.2", + "chai": "4.2.0", + "mocha": "6.1.3", + "rollup": "1.10.1", + "rollup-plugin-replace": "2.2.0", + "rollup-plugin-typescript2": "0.21.0", + "sinon": "7.3.2", + "sinon-chai": "3.3.0", + "typescript": "3.4.5" + }, + "repository": { + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk/tree/master/packages/performance" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} diff --git a/packages/performance/rollup.config.js b/packages/performance/rollup.config.js new file mode 100644 index 00000000000..88f7345da6c --- /dev/null +++ b/packages/performance/rollup.config.js @@ -0,0 +1,83 @@ +/** + * Copyright 2018 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 typescriptPlugin from 'rollup-plugin-typescript2'; +import replace from 'rollup-plugin-replace'; +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 + }), + replace({ + delimiters: ['<', '>'], + values: { + PERF_SDK_VERSION: pkg.version + } + }) +]; + +const es5Builds = [ + { + input: 'index.ts', + output: [ + { file: pkg.main, 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' + } + } + }), + replace({ + delimiters: ['<', '>'], + values: { + PERF_SDK_VERSION: pkg.version + } + }) +]; + +const es2017Builds = [ + { + 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]; diff --git a/packages/performance/src/constants.ts b/packages/performance/src/constants.ts new file mode 100644 index 00000000000..de3c5fdffe9 --- /dev/null +++ b/packages/performance/src/constants.ts @@ -0,0 +1,39 @@ +/** + * 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. + */ + +export const SDK_VERSION = ''; +/** The prefix for start User Timing marks used for creating Traces. */ +export const TRACE_START_MARK_PREFIX = 'FB-PERF-TRACE-START'; +/** The prefix for stop User Timing marks used for creating Traces. */ +export const TRACE_STOP_MARK_PREFIX = 'FB-PERF-TRACE-STOP'; +/** The prefix for User Timing measure used for creating Traces. */ +export const TRACE_MEASURE_PREFIX = 'FB-PERF-TRACE-MEASURE'; +/** The prefix for out of the box page load Trace name. */ +export const OOB_TRACE_PAGE_LOAD_PREFIX = '_wt_'; + +export const FIRST_PAINT_COUNTER_NAME = '_fp'; + +export const FIRST_CONTENTFUL_PAINT_COUNTER_NAME = '_fcp'; + +export const FIRST_INPUT_DELAY_COUNTER_NAME = '_fid'; + +export const CONFIG_LOCAL_STORAGE_KEY = '@firebase/performance/config'; + +export const CONFIG_EXPIRY_LOCAL_STORAGE_KEY = + '@firebase/performance/configexpire'; + +export const SERVICE = 'performance'; +export const SERVICE_NAME = 'Performance'; diff --git a/packages/performance/src/controllers/perf.test.ts b/packages/performance/src/controllers/perf.test.ts new file mode 100644 index 00000000000..7df7a650116 --- /dev/null +++ b/packages/performance/src/controllers/perf.test.ts @@ -0,0 +1,95 @@ +/** + * @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 { PerformanceController } from '../controllers/perf'; +import { Trace } from '../resources/trace'; +import { setupApi } from '../services/api_service'; +import { FirebaseApp } from '@firebase/app-types'; + +describe('Firebase Performance Test', () => { + setupApi(window); + + let fakeFirebaseConfig = { + apiKey: 'api-key', + authDomain: 'project-id.firebaseapp.com', + databaseURL: 'https://project-id.firebaseio.com', + projectId: 'project-id', + storageBucket: 'project-id.appspot.com', + messagingSenderId: 'sender-id', + appId: '1:111:web:a1234' + }; + + let fakeFirebaseApp = ({ + options: fakeFirebaseConfig + } as unknown) as FirebaseApp; + + describe('#trace', () => { + it('creates a custom trace', () => { + const controller = new PerformanceController(fakeFirebaseApp); + const myTrace = controller.trace('myTrace'); + + expect(myTrace).to.be.instanceOf(Trace); + }); + + it('custom trace has the correct name', () => { + const controller = new PerformanceController(fakeFirebaseApp); + const myTrace = controller.trace('myTrace'); + + expect(myTrace.name).is.equal('myTrace'); + }); + + it('custom trace is not auto', () => { + const controller = new PerformanceController(fakeFirebaseApp); + const myTrace = controller.trace('myTrace'); + + expect(myTrace.isAuto).is.equal(false); + }); + }); + + describe('#instrumentationEnabled', () => { + it('sets instrumentationEnabled to enabled', async () => { + const controller = new PerformanceController(fakeFirebaseApp); + + controller.instrumentationEnabled = true; + expect(controller.instrumentationEnabled).is.equal(true); + }); + + it('sets instrumentationEnabled to disabled', async () => { + const controller = new PerformanceController(fakeFirebaseApp); + + controller.instrumentationEnabled = false; + expect(controller.instrumentationEnabled).is.equal(false); + }); + }); + + describe('#dataCollectionEnabled', () => { + it('sets dataCollectionEnabled to enabled', async () => { + const controller = new PerformanceController(fakeFirebaseApp); + + controller.dataCollectionEnabled = true; + expect(controller.dataCollectionEnabled).is.equal(true); + }); + + it('sets dataCollectionEnabled to disabled', () => { + const controller = new PerformanceController(fakeFirebaseApp); + + controller.dataCollectionEnabled = false; + expect(controller.dataCollectionEnabled).is.equal(false); + }); + }); +}); diff --git a/packages/performance/src/controllers/perf.ts b/packages/performance/src/controllers/perf.ts new file mode 100644 index 00000000000..9ba4ab633d5 --- /dev/null +++ b/packages/performance/src/controllers/perf.ts @@ -0,0 +1,45 @@ +/** + * 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 { Trace } from '../resources/trace'; +import { setupOobResources } from '../services/oob_resources_service'; +import { SettingsService } from '../services/settings_service'; +import { getInitializationPromise } from '../services/initialization_service'; +import { FirebaseApp } from '@firebase/app-types'; +import { FirebasePerformance } from '@firebase/performance-types'; + +export class PerformanceController implements FirebasePerformance { + constructor(readonly app: FirebaseApp) { + getInitializationPromise().then(setupOobResources, setupOobResources); + } + + trace(name: string): Trace { + return new Trace(name); + } + + set instrumentationEnabled(val: boolean) { + SettingsService.getInstance().instrumentationEnabled = val; + } + get instrumentationEnabled() { + return SettingsService.getInstance().instrumentationEnabled; + } + + set dataCollectionEnabled(val: boolean) { + SettingsService.getInstance().dataCollectionEnabled = val; + } + get dataCollectionEnabled() { + return SettingsService.getInstance().dataCollectionEnabled; + } +} diff --git a/packages/performance/src/resources/network_request.test.ts b/packages/performance/src/resources/network_request.test.ts new file mode 100644 index 00000000000..9f34b05b548 --- /dev/null +++ b/packages/performance/src/resources/network_request.test.ts @@ -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 { stub, restore } from 'sinon'; +import { createNetworkRequestEntry } from '../../src/resources/network_request'; +import { expect } from 'chai'; +import { Api } from '../services/api_service'; +import * as perfLogger from '../services/perf_logger'; +import { setupApi } from '../services/api_service'; +import '../../test/setup'; + +describe('Firebase Performance > network_request', () => { + setupApi(window); + + beforeEach(() => { + stub(Api.prototype, 'getTimeOrigin').returns(1528521843799.5032); + stub(perfLogger, 'logNetworkRequest'); + }); + + afterEach(() => { + restore(); + }); + + describe('#createNetworkRequestEntry', () => { + it('logs network request when all required fields present', () => { + const PERFORMANCE_ENTRY = ({ + name: 'http://some.test.website.com', + transferSize: 500, + startTime: 1645352.632345, + responseStart: 1645360.244323, + responseEnd: 1645360.832443 + } as unknown) as PerformanceResourceTiming; + + const EXPECTED_NETWORK_REQUEST = { + url: 'http://some.test.website.com', + responsePayloadBytes: 500, + startTimeUs: 1528523489152135, + timeToResponseInitiatedUs: 7611, + timeToResponseCompletedUs: 8200 + }; + + createNetworkRequestEntry(PERFORMANCE_ENTRY); + + expect( + (perfLogger.logNetworkRequest as any).calledWith( + EXPECTED_NETWORK_REQUEST + ) + ).to.be.true; + }); + + it('doesnt log network request when responseStart is absent', () => { + const PERFORMANCE_ENTRY = ({ + name: 'http://some.test.website.com', + transferSize: 500, + startTime: 1645352.632345, + responseEnd: 1645360.832443 + } as unknown) as PerformanceResourceTiming; + + createNetworkRequestEntry(PERFORMANCE_ENTRY); + + expect(perfLogger.logNetworkRequest).to.not.have.been.called; + }); + }); +}); diff --git a/packages/performance/src/resources/network_request.ts b/packages/performance/src/resources/network_request.ts new file mode 100644 index 00000000000..1c19a20a159 --- /dev/null +++ b/packages/performance/src/resources/network_request.ts @@ -0,0 +1,75 @@ +/** + * @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 { Api } from '../services/api_service'; +import { logNetworkRequest } from '../services/perf_logger'; + +// The order of values of this enum should not be changed. +export const enum HttpMethod { + HTTP_METHOD_UNKNOWN = 0, + GET = 1, + PUT = 2, + POST = 3, + DELETE = 4, + HEAD = 5, + PATCH = 6, + OPTIONS = 7, + TRACE = 8, + CONNECT = 9 +} + +// Durations are in microseconds. +export interface NetworkRequest { + url: string; + httpMethod?: HttpMethod; + requestPayloadBytes?: number; + responsePayloadBytes?: number; + httpResponseCode?: number; + responseContentType?: string; + startTimeUs?: number; + timeToRequestCompletedUs?: number; + timeToResponseInitiatedUs?: number; + timeToResponseCompletedUs?: number; +} + +export function createNetworkRequestEntry(entry: PerformanceEntry): void { + const performanceEntry = entry as PerformanceResourceTiming; + if (!performanceEntry || performanceEntry.responseStart === undefined) return; + const timeOrigin = Api.getInstance().getTimeOrigin(); + const startTimeUs = Math.floor( + (performanceEntry.startTime + timeOrigin) * 1000 + ); + const timeToResponseInitiatedUs = performanceEntry.responseStart + ? Math.floor( + (performanceEntry.responseStart - performanceEntry.startTime) * 1000 + ) + : undefined; + const timeToResponseCompletedUs = Math.floor( + (performanceEntry.responseEnd - performanceEntry.startTime) * 1000 + ); + // Remove the query params from logged network request url. + const url = performanceEntry.name && performanceEntry.name.split('?')[0]; + const networkRequest: NetworkRequest = { + url, + responsePayloadBytes: performanceEntry.transferSize, + startTimeUs, + timeToResponseInitiatedUs, + timeToResponseCompletedUs + }; + + logNetworkRequest(networkRequest); +} diff --git a/packages/performance/src/resources/trace.test.ts b/packages/performance/src/resources/trace.test.ts new file mode 100644 index 00000000000..85f4840c163 --- /dev/null +++ b/packages/performance/src/resources/trace.test.ts @@ -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 { spy, stub, restore } from 'sinon'; +import { Trace } from '../resources/trace'; +import { expect } from 'chai'; +import { Api } from '../services/api_service'; +import * as perfLogger from '../services/perf_logger'; +import { setupApi } from '../services/api_service'; +import '../../test/setup'; + +describe('Firebase Performance > trace', () => { + setupApi(window); + let trace: Trace; + const createTrace = () => { + return new Trace('test'); + }; + + beforeEach(() => { + spy(Api.prototype, 'mark'); + stub(perfLogger, 'logTrace'); + trace = createTrace(); + }); + + afterEach(() => { + restore(); + }); + + describe('#start', () => { + beforeEach(() => { + trace.start(); + }); + + it('uses the underlying api method', () => { + expect(Api.getInstance().mark).to.be.calledOnce; + }); + + it('throws if a trace is started twice', () => { + expect(() => trace.start()).to.throw(); + }); + }); + + describe('#stop', () => { + it('adds a mark to the performance timeline', () => { + trace.start(); + trace.stop(); + + expect(Api.getInstance().mark).to.be.calledTwice; + }); + + it('logs the trace', () => { + trace.start(); + trace.stop(); + + expect((perfLogger.logTrace as any).calledOnceWith(trace)).to.be.true; + }); + }); + + describe('#record', () => { + it('logs a trace without metrics or custom attributes', () => { + trace.record(1, 20); + + expect((perfLogger.logTrace as any).calledOnceWith(trace)).to.be.true; + }); + + it('logs a trace with metrics', () => { + trace.record(1, 20, { metrics: { cacheHits: 1 } }); + + expect((perfLogger.logTrace as any).calledOnceWith(trace)).to.be.true; + expect(trace.getMetric('cacheHits')).to.eql(1); + }); + + it('logs a trace with custom attributes', () => { + trace.record(1, 20, { attributes: { level: '1' } }); + + expect((perfLogger.logTrace as any).calledOnceWith(trace)).to.be.true; + expect(trace.getAttributes()).to.eql({ level: '1' }); + }); + + it('logs a trace with custom attributes and metrics', () => { + trace.record(1, 20, { + attributes: { level: '1' }, + metrics: { cacheHits: 1 } + }); + + expect((perfLogger.logTrace as any).calledOnceWith(trace)).to.be.true; + expect(trace.getAttributes()).to.eql({ level: '1' }); + expect(trace.getMetric('cacheHits')).to.eql(1); + }); + }); + + describe('#incrementMetric', () => { + it('creates new metric if one doesnt exist.', () => { + trace.incrementMetric('cacheHits', 200); + + expect(trace.getMetric('cacheHits')).to.eql(200); + }); + + it('increments metric if it already exists.', () => { + trace.incrementMetric('cacheHits', 200); + trace.incrementMetric('cacheHits', 400); + + expect(trace.getMetric('cacheHits')).to.eql(600); + }); + }); + + describe('#putMetric', () => { + it('creates new metric if one doesnt exist.', () => { + trace.putMetric('cacheHits', 200); + + expect(trace.getMetric('cacheHits')).to.eql(200); + }); + + it('replaces metric if it already exists.', () => { + trace.putMetric('cacheHits', 200); + trace.putMetric('cacheHits', 400); + + expect(trace.getMetric('cacheHits')).to.eql(400); + }); + }); + + describe('#getMetric', () => { + it('returns 0 if metric doesnt exist', () => { + expect(trace.getMetric('doesThisExist')).to.equal(0); + }); + + it('returns 0 if it exists and equals 0', () => { + trace.putMetric('cacheHits', 0); + + expect(trace.getMetric('cacheHits')).to.equal(0); + }); + + it('returns metric if it exists', () => { + trace.putMetric('cacheHits', 200); + + expect(trace.getMetric('cacheHits')).to.equal(200); + }); + + it('returns multiple metrics if they exist', () => { + trace.putMetric('cacheHits', 200); + trace.putMetric('bytesDownloaded', 25); + + expect(trace.getMetric('cacheHits')).to.equal(200); + expect(trace.getMetric('bytesDownloaded')).to.equal(25); + }); + }); + + describe('#putAttribute', () => { + it('creates new attribute if it doesnt exist', () => { + trace.putAttribute('level', '4'); + + expect(trace.getAttributes()).to.eql({ level: '4' }); + }); + + it('replaces attribute if it exists', () => { + trace.putAttribute('level', '4'); + trace.putAttribute('level', '7'); + + expect(trace.getAttributes()).to.eql({ level: '7' }); + }); + }); + + describe('#getAttribute', () => { + it('returns undefined for attribute that doesnt exist', () => { + expect(trace.getAttribute('level')).to.be.undefined; + }); + + it('returns attribute if it exists', () => { + trace.putAttribute('level', '4'); + expect(trace.getAttribute('level')).to.equal('4'); + }); + + it('returns separate attributes if they exist', () => { + trace.putAttribute('level', '4'); + trace.putAttribute('stage', 'beginning'); + + expect(trace.getAttribute('level')).to.equal('4'); + expect(trace.getAttribute('stage')).to.equal('beginning'); + }); + }); + + describe('#removeAttribute', () => { + it('does not throw if removing attribute that doesnt exist', () => { + expect(() => trace.removeAttribute('doesNotExist')).to.not.throw; + }); + + it('removes attribute if it exists', () => { + trace.putAttribute('level', '4'); + expect(trace.getAttribute('level')).to.equal('4'); + + trace.removeAttribute('level'); + expect(trace.getAttribute('level')).to.be.undefined; + }); + + it('retains other attributes', () => { + trace.putAttribute('level', '4'); + trace.putAttribute('stage', 'beginning'); + + trace.removeAttribute('level'); + expect(trace.getAttribute('level')).to.be.undefined; + expect(trace.getAttribute('stage')).to.equal('beginning'); + }); + }); +}); diff --git a/packages/performance/src/resources/trace.ts b/packages/performance/src/resources/trace.ts new file mode 100644 index 00000000000..dedf75d540d --- /dev/null +++ b/packages/performance/src/resources/trace.ts @@ -0,0 +1,297 @@ +/** + * @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 { + TRACE_START_MARK_PREFIX, + TRACE_STOP_MARK_PREFIX, + TRACE_MEASURE_PREFIX, + OOB_TRACE_PAGE_LOAD_PREFIX, + FIRST_PAINT_COUNTER_NAME, + FIRST_CONTENTFUL_PAINT_COUNTER_NAME, + FIRST_INPUT_DELAY_COUNTER_NAME +} from '../constants'; +import { Api } from '../services/api_service'; +import { logTrace } from '../services/perf_logger'; +import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; +import { PerformanceTrace } from '@firebase/performance-types'; + +const enum TraceState { + UNINITIALIZED = 1, + RUNNING, + TERMINATED +} + +export class Trace implements PerformanceTrace { + private state: TraceState = TraceState.UNINITIALIZED; + startTimeUs!: number; + durationUs!: number; + private customAttributes: { [key: string]: string } = {}; + counters: { [counterName: string]: number } = {}; + private api = Api.getInstance(); + private randomId = Math.floor(Math.random() * 1000000); + private traceStartMark!: string; + private traceStopMark!: string; + private traceMeasure!: string; + + /** + * @param name The name of the trace. + * @param isAuto If the trace is auto-instrumented. + * @param traceMeasureName The name of the measure marker in user timing specification. This field + * is only set when the trace is built for logging when the user directly uses the user timing + * api (performance.mark and performance.measure). + */ + constructor( + readonly name: string, + readonly isAuto = false, + traceMeasureName?: string + ) { + if (!this.isAuto) { + this.traceStartMark = `${TRACE_START_MARK_PREFIX}-${this.randomId}-${ + this.name + }`; + this.traceStopMark = `${TRACE_STOP_MARK_PREFIX}-${this.randomId}-${ + this.name + }`; + this.traceMeasure = + traceMeasureName || + `${TRACE_MEASURE_PREFIX}-${this.randomId}-${this.name}`; + + if (traceMeasureName) { + // For the case of direct user timing traces, no start stop will happen. The measure object + // is already available. + this.calculateTraceMetrics(); + } + } + } + + /** + * Starts a trace. The measurement of the duration starts at this point. + */ + start(): void { + if (this.state !== TraceState.UNINITIALIZED) { + throw ERROR_FACTORY.create(ErrorCode.TRACE_STARTED_BEFORE, { + traceName: this.name + }); + } + this.api.mark(this.traceStartMark); + this.state = TraceState.RUNNING; + } + + /** + * Stops the trace. The measurement of the duration of the trace stops at this point and trace + * is logged. + */ + stop(): void { + if (this.state !== TraceState.RUNNING) { + throw ERROR_FACTORY.create(ErrorCode.TRACE_STOPPED_BEFORE, { + traceName: this.name + }); + } + this.state = TraceState.TERMINATED; + this.api.mark(this.traceStopMark); + this.api.measure( + this.traceMeasure, + this.traceStartMark, + this.traceStopMark + ); + this.calculateTraceMetrics(); + logTrace(this); + } + + /** + * Records a trace with predetermined values. If this method is used a trace is created and logged + * directly. No need to use start and stop methods. + * @param startTime Trace start time since epoch in millisec + * @param duration The duraction of the trace in millisec + * @param options An object which can optionally hold maps of custom metrics and custom attributes + */ + record( + startTime: number, + duration: number, + options?: { + metrics?: { [key: string]: number }; + attributes?: { [key: string]: string }; + } + ): void { + this.durationUs = Math.floor(duration * 1000); + this.startTimeUs = Math.floor(startTime * 1000); + if (options && options.attributes) { + this.customAttributes = { ...options.attributes }; + } + if (options && options.metrics) { + for (const metric of Object.keys(options.metrics)) { + if (!isNaN(Number(options.metrics[metric]))) { + this.counters[metric] = Number(Math.floor(options.metrics[metric])); + } + } + } + logTrace(this); + } + + /** + * Increments a custom metric by a certain number or 1 if number not specified. Will create a new + * custom metric if one with the given name does not exist. + * @param counter Name of the custom metric + * @param num Increment by value + */ + incrementMetric(counter: string, num = 1): void { + if (this.counters[counter] === undefined) { + this.counters[counter] = 0; + } + this.counters[counter] += num; + } + + /** + * Sets a custom metric to a specified value. Will create a new custom metric if one with the + * given name does not exist. + * @param counter Name of the custom metric + * @param num Set custom metric to this value + */ + putMetric(counter: string, num: number): void { + this.counters[counter] = num; + } + + /** + * Returns the value of the custom metric by that name. If a custom metric with that name does + * not exist will return zero. + * @param counter + */ + getMetric(counter: string): number { + return this.counters[counter] || 0; + } + + /** + * Sets a custom attribute of a trace to a certain value. + * @param attr + * @param value + */ + putAttribute(attr: string, value: string): void { + this.customAttributes[attr] = value; + } + + /** + * Retrieves the value a custom attribute of a trace is set to. + * @param attr + */ + getAttribute(attr: string): string | undefined { + return this.customAttributes[attr]; + } + + removeAttribute(attr: string): void { + if (this.customAttributes[attr] === undefined) return; + delete this.customAttributes[attr]; + } + + getAttributes(): { [key: string]: string } { + return { ...this.customAttributes }; + } + + private setStartTime(startTime: number): void { + this.startTimeUs = startTime; + } + + private setDuration(duration: number): void { + this.durationUs = duration; + } + + /** + * Calculates and assigns the duration and start time of the trace using the measure performance + * entry. + */ + private calculateTraceMetrics(): void { + const perfMeasureEntries = this.api.getEntriesByName(this.traceMeasure); + const perfMeasureEntry = perfMeasureEntries && perfMeasureEntries[0]; + if (perfMeasureEntry) { + this.durationUs = Math.floor(perfMeasureEntry.duration * 1000); + this.startTimeUs = Math.floor( + (perfMeasureEntry.startTime + this.api.getTimeOrigin()) * 1000 + ); + } + } + + /** + * @param navigationTimings A single element array which contains the navigationTIming object of + * the page load + * @param paintTimings A array which contains paintTiming object of the page load + * @param firstInputDelay First input delay in millisec + */ + static createOobTrace( + navigationTimings: PerformanceNavigationTiming[], + paintTimings: PerformanceEntry[], + firstInputDelay?: number + ): void { + const route = Api.getInstance().getUrl(); + if (!route) return; + const trace = new Trace(OOB_TRACE_PAGE_LOAD_PREFIX + route, true); + const timeOriginUs = Math.floor(Api.getInstance().getTimeOrigin() * 1000); + trace.setStartTime(timeOriginUs); + + // navigationTimings includes only one element. + if (navigationTimings && navigationTimings[0]) { + trace.setDuration(Math.floor(navigationTimings[0].duration * 1000)); + trace.incrementMetric( + 'domInteractive', + Math.floor(navigationTimings[0].domInteractive * 1000) + ); + trace.incrementMetric( + 'domContentLoadedEventEnd', + Math.floor(navigationTimings[0].domContentLoadedEventEnd * 1000) + ); + trace.incrementMetric( + 'loadEventEnd', + Math.floor(navigationTimings[0].loadEventEnd * 1000) + ); + } + + const FIRST_PAINT = 'first-paint'; + const FIRST_CONTENTFUL_PAINT = 'first-contentful-paint'; + if (paintTimings) { + const firstPaint = paintTimings.find( + paintObject => paintObject.name === FIRST_PAINT + ); + if (firstPaint && firstPaint.startTime) { + trace.incrementMetric( + FIRST_PAINT_COUNTER_NAME, + Math.floor(firstPaint.startTime * 1000) + ); + } + const firstContentfulPaint = paintTimings.find( + paintObject => paintObject.name === FIRST_CONTENTFUL_PAINT + ); + if (firstContentfulPaint && firstContentfulPaint.startTime) { + trace.incrementMetric( + FIRST_CONTENTFUL_PAINT_COUNTER_NAME, + Math.floor(firstContentfulPaint.startTime * 1000) + ); + } + + if (firstInputDelay) { + trace.incrementMetric( + FIRST_INPUT_DELAY_COUNTER_NAME, + Math.floor(firstInputDelay * 1000) + ); + } + } + + logTrace(trace); + } + + static createUserTimingTrace(measureName: string): void { + const trace = new Trace(measureName, false, measureName); + logTrace(trace); + } +} diff --git a/packages/performance/src/services/api_service.test.ts b/packages/performance/src/services/api_service.test.ts new file mode 100644 index 00000000000..36cb3317039 --- /dev/null +++ b/packages/performance/src/services/api_service.test.ts @@ -0,0 +1,111 @@ +/** + * @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 { stub } from 'sinon'; +import { expect } from 'chai'; +import { Api, setupApi } from './api_service'; +import '../../test/setup'; + +describe('Firebase Performance > api_service', () => { + const PAGE_URL = 'http://www.test.com/abcd?a=2'; + const PERFORMANCE_ENTRY: PerformanceEntry = { + duration: 0, + entryType: 'paint', + name: 'first-contentful-paint', + startTime: 149.01000005193055, + toJSON: () => {} + }; + + const mockWindow = { ...self }; + + let api: Api; + + beforeEach(() => { + stub(mockWindow.performance, 'mark'); + stub(mockWindow.performance, 'measure'); + stub(mockWindow.performance, 'getEntriesByType').returns([ + PERFORMANCE_ENTRY + ]); + stub(mockWindow.performance, 'getEntriesByName').returns([ + PERFORMANCE_ENTRY + ]); + // This is to make sure the test page is not changed by changing the href of location object. + mockWindow.location = { ...self.location, href: PAGE_URL }; + setupApi(mockWindow); + api = Api.getInstance(); + }); + + describe('getUrl', () => { + it('removes the query params', () => { + expect(api.getUrl()).to.equal('http://www.test.com/abcd'); + }); + }); + + describe('mark', () => { + it('creates performance mark', () => { + const MARK_NAME = 'mark1'; + api.mark(MARK_NAME); + + expect(mockWindow.performance.mark).to.be.calledOnceWith(MARK_NAME); + }); + }); + + describe('measure', () => { + it('creates a performance measure', () => { + const MEASURE_NAME = 'measure1'; + const MARK_1_NAME = 'mark1'; + const MARK_2_NAME = 'mark2'; + api.measure(MEASURE_NAME, MARK_1_NAME, MARK_2_NAME); + + expect(mockWindow.performance.measure).to.be.calledOnceWith( + MEASURE_NAME, + MARK_1_NAME, + MARK_2_NAME + ); + }); + }); + + describe('getEntriesByType', () => { + it('calls the underlying performance api', () => { + expect(api.getEntriesByType('paint')).to.deep.equal([PERFORMANCE_ENTRY]); + }); + + it('does not throw if the browser does not include underlying api', () => { + api = new Api(({ performance: undefined } as unknown) as Window); + + expect(() => { + api.getEntriesByType('paint'); + }).to.not.throw(); + expect(api.getEntriesByType('paint')).to.deep.equal([]); + }); + }); + + describe('getEntriesByName', () => { + it('calls the underlying performance api', () => { + expect(api.getEntriesByName('paint')).to.deep.equal([PERFORMANCE_ENTRY]); + }); + + it('does not throw if the browser does not include underlying api', () => { + api = new Api(({ performance: undefined } as any) as Window); + + expect(() => { + api.getEntriesByName('paint'); + }).to.not.throw(); + expect(api.getEntriesByName('paint')).to.deep.equal([]); + }); + }); +}); diff --git a/packages/performance/src/services/api_service.ts b/packages/performance/src/services/api_service.ts new file mode 100644 index 00000000000..89a02c32072 --- /dev/null +++ b/packages/performance/src/services/api_service.ts @@ -0,0 +1,125 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../utils/errors'; + +declare global { + interface Window { + PerformanceObserver: typeof PerformanceObserver; + perfMetrics?: { onFirstInputDelay: Function }; + } +} + +let apiInstance: Api | undefined; +let windowInstance: Window | undefined; + +export type EntryType = + | 'mark' + | 'measure' + | 'paint' + | 'resource' + | 'frame' + | 'navigation'; + +/** + * This class holds a reference to various browser related objects injected by set methods. + */ +export class Api { + private performance: Performance; + /** PreformanceObserver constructor function. */ + private PerformanceObserver: typeof PerformanceObserver; + private windowLocation: Location; + onFirstInputDelay?: Function; + localStorage: Storage; + document: Document; + navigator: Navigator; + + constructor(window?: Window) { + if (!window) { + throw ERROR_FACTORY.create(ErrorCode.NO_WINDOW); + } + this.performance = window.performance; + this.PerformanceObserver = window.PerformanceObserver; + this.windowLocation = window.location; + this.navigator = window.navigator; + this.document = window.document; + this.localStorage = window.localStorage; + if (window.perfMetrics && window.perfMetrics.onFirstInputDelay) { + this.onFirstInputDelay = window.perfMetrics.onFirstInputDelay; + } + } + + getUrl(): string { + // Do not capture the string query part of url. + return this.windowLocation.href.split('?')[0]; + } + + mark(name: string): void { + if (!this.performance || !this.performance.mark) return; + this.performance.mark(name); + } + + measure(measureName: string, mark1: string, mark2: string): void { + if (!this.performance || !this.performance.measure) return; + this.performance.measure(measureName, mark1, mark2); + } + + getEntriesByType(type: EntryType): PerformanceEntry[] { + if (!this.performance || !this.performance.getEntriesByType) return []; + return this.performance.getEntriesByType(type); + } + + getEntriesByName(name: string): PerformanceEntry[] { + if (!this.performance || !this.performance.getEntriesByName) return []; + return this.performance.getEntriesByName(name); + } + + getTimeOrigin(): number { + // Polyfill the time origin with performance.timing.navigationStart. + return ( + this.performance && + (this.performance.timeOrigin || this.performance.timing.navigationStart) + ); + } + + setupObserver( + entryType: EntryType, + callback: (entry: PerformanceEntry) => void + ): void { + if (!this.PerformanceObserver) return; + const observer = new this.PerformanceObserver(list => { + for (const entry of list.getEntries()) { + // `entry` is a PerformanceEntry instance. + callback(entry); + } + }); + + // Start observing the entry types you care about. + observer.observe({ entryTypes: [entryType] }); + } + + static getInstance(): Api { + if (apiInstance === undefined) { + apiInstance = new Api(windowInstance); + } + return apiInstance; + } +} + +export function setupApi(window: Window): void { + windowInstance = window; +} diff --git a/packages/performance/src/services/cc_service.test.ts b/packages/performance/src/services/cc_service.test.ts new file mode 100644 index 00000000000..55c43ab155a --- /dev/null +++ b/packages/performance/src/services/cc_service.test.ts @@ -0,0 +1,86 @@ +/** + * @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 { stub, useFakeTimers } from 'sinon'; +import { use, expect } from 'chai'; +import { Logger, LogLevel } from '@firebase/logger'; +import * as sinonChai from 'sinon-chai'; +import { SinonStub } from 'sinon'; +use(sinonChai); + +// We have to stub the clock before importing cc_service, otherwise we cannot deterministically +// trigger fetches. +// Starts date at timestamp 1 instead of 0, otherwise it causes validation errors. +let clock = useFakeTimers(1); +import { ccHandler } from './cc_service'; + +describe('Firebase Performance > cc_service', () => { + after(() => { + clock.restore(); + }); + + describe('ccHandler', () => { + let fetchStub: SinonStub<[RequestInfo, RequestInit?], Promise>; + const INITIAL_SEND_TIME_DELAY_MS = 5.5 * 1000; + const DEFAULT_SEND_INTERVAL_MS = 10 * 1000; + let testCCHandler = ccHandler((...args) => { + return args[0]; + }); + + beforeEach(() => { + fetchStub = stub(window, 'fetch'); + }); + + afterEach(() => { + fetchStub.restore(); + }); + + it('throws an error when logging an empty message', () => { + let logger = new Logger('@firebase/performance/cc'); + expect(() => { + testCCHandler(logger, LogLevel.SILENT, ''); + }).to.throw; + }); + + it('does not attempt to log an event to clearcut after INITIAL_SEND_TIME_DELAY_MS if queue is empty', () => { + fetchStub.resolves( + new Response('', { + status: 200, + headers: { 'Content-type': 'application/json' } + }) + ); + + clock.tick(INITIAL_SEND_TIME_DELAY_MS); + expect(fetchStub).to.not.have.been.called; + }); + + it('attempts to log an event to clearcut after DEFAULT_SEND_INTERVAL_MS if queue not empty', () => { + let logger = new Logger('@firebase/performance/cc'); + + fetchStub.resolves( + new Response('', { + status: 200, + headers: { 'Content-type': 'application/json' } + }) + ); + + testCCHandler(logger, LogLevel.SILENT, 'someEvent'); + clock.tick(DEFAULT_SEND_INTERVAL_MS); + expect(fetchStub).to.have.been.calledOnce; + }); + }); +}); diff --git a/packages/performance/src/services/cc_service.ts b/packages/performance/src/services/cc_service.ts new file mode 100644 index 00000000000..b922e03c2f3 --- /dev/null +++ b/packages/performance/src/services/cc_service.ts @@ -0,0 +1,140 @@ +/** + * @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 { LogHandler, Logger, LogLevel } from '@firebase/logger'; +import { SettingsService } from './settings_service'; +import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; +import { consoleLogger } from '../utils/console_logger'; + +const DEFAULT_SEND_INTERVAL_MS = 10 * 1000; +const INITIAL_SEND_TIME_DELAY_MS = 5.5 * 1000; +// If end point does not work, the call will be tried for these many times. +const DEFAULT_REMAINING_TRIES = 3; +let remainingTries = DEFAULT_REMAINING_TRIES; + +interface BatchEvent { + message: string; + eventTime: number; +} + +// CC accepted log format. +interface CcBatchLogFormat { + request_time_ms: string; + client_info: ClientInfo; + log_source: number; + log_event: Log[]; +} + +interface ClientInfo { + client_type: number; + js_client_info: {}; +} + +interface Log { + source_extension_json: string; + event_time_ms: string; +} + +let queue: BatchEvent[] = []; + +function processQueue(timeOffset: number): void { + setTimeout(() => { + // If there is no remainingTries left, stop retrying. + if (remainingTries === 0) { + return; + } + + // If there are no events to process, wait for DEFAULT_SEND_INTERVAL_MS and try again. + if (!queue.length) { + return processQueue(DEFAULT_SEND_INTERVAL_MS); + } + + // Capture a snapshot of the queue and empty the "official queue". + const staged = [...queue]; + queue = []; + + // We will pass the JSON serialized event to the backend. + const log_event = staged.map(evt => ({ + source_extension_json: evt.message, + event_time_ms: String(evt.eventTime) + })); + + const data: CcBatchLogFormat = { + request_time_ms: String(Date.now()), + client_info: { + client_type: 1, // 1 is JS + js_client_info: {} + }, + log_source: SettingsService.getInstance().logSource, + log_event + }; + + fetch(SettingsService.getInstance().logEndPointUrl, { + method: 'POST', + body: JSON.stringify(data) + }) + .then(res => { + if (!res.ok) { + consoleLogger.info('Call to Firebase backend failed.'); + } + return res.json(); + }) + .then(res => { + // Find the next call wait time from the response. + const requestOffset = Math.max( + DEFAULT_SEND_INTERVAL_MS, + parseInt(res.next_request_wait_millis, 10) + ); + remainingTries = DEFAULT_REMAINING_TRIES; + // Schedule the next process. + processQueue(requestOffset); + }) + .catch(() => { + /** + * If the request fails for some reason, add the events that were attempted + * back to the primary queue to retry later. + */ + queue = [...staged, ...queue]; + remainingTries--; + consoleLogger.info(`Tries left: ${remainingTries}.`); + processQueue(DEFAULT_SEND_INTERVAL_MS); + }); + }, timeOffset); +} + +processQueue(INITIAL_SEND_TIME_DELAY_MS); + +function addToQueue(evt: BatchEvent): void { + if (!evt.eventTime || !evt.message) { + throw ERROR_FACTORY.create(ErrorCode.INVALID_CC_LOG); + } + // Add the new event to the queue. + queue = [...queue, evt]; +} + +/** Log handler for cc service to send the performance logs to the server. */ +export function ccHandler(serializer: (...args: any[]) => string): LogHandler { + // The underscores for loggerInstance and level parameters are added to avoid the + // noUnusedParameters related error. + return (_loggerInstance: Logger, _level: LogLevel, ...args) => { + const message = serializer(...args); + addToQueue({ + message, + eventTime: Date.now() + }); + }; +} diff --git a/packages/performance/src/services/iid_service.test.ts b/packages/performance/src/services/iid_service.test.ts new file mode 100644 index 00000000000..36bc6e2afbf --- /dev/null +++ b/packages/performance/src/services/iid_service.test.ts @@ -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 { stub } from 'sinon'; +import { expect } from 'chai'; +import { SettingsService } from './settings_service'; +import { + getIid, + getIidPromise, + getAuthenticationToken, + getAuthTokenPromise +} from './iid_service'; +import { FirebaseApp } from '@firebase/app-types'; +import '../../test/setup'; + +describe('Firebase Perofmrance > iid_service', () => { + const IID = 'fid'; + const AUTH_TOKEN = 'authToken'; + const getId = stub().resolves(IID); + const getToken = stub().resolves(AUTH_TOKEN); + + SettingsService.prototype.firebaseAppInstance = ({ + installations: () => ({ getId, getToken }) + } as unknown) as FirebaseApp; + + describe('getIidPromise', () => { + it('provides iid', async () => { + const iid = await getIidPromise(); + + expect(iid).to.be.equal(IID); + expect(getIid()).to.be.equal(IID); + }); + }); + + describe('getAuthTokenPromise', () => { + it('provides authentication token', async () => { + const token = await getAuthTokenPromise(); + + expect(token).to.be.equal(AUTH_TOKEN); + expect(getAuthenticationToken()).to.be.equal(AUTH_TOKEN); + }); + }); +}); diff --git a/packages/performance/src/services/iid_service.ts b/packages/performance/src/services/iid_service.ts new file mode 100644 index 00000000000..0415337ca68 --- /dev/null +++ b/packages/performance/src/services/iid_service.ts @@ -0,0 +1,52 @@ +/** + * @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/installations'; +import { SettingsService } from './settings_service'; + +let iid: string | undefined; +let authToken: string | undefined; + +export function getIidPromise(): Promise { + const iidPromise = SettingsService.getInstance() + .firebaseAppInstance.installations() + .getId(); + + iidPromise.then((iidVal: string) => { + iid = iidVal; + }); + return iidPromise; +} + +// This method should be used after the iid is retrieved by getIidPromise method. +export function getIid(): string | undefined { + return iid; +} + +export function getAuthTokenPromise(): Promise { + const authTokenPromise = SettingsService.getInstance() + .firebaseAppInstance.installations() + .getToken(); + authTokenPromise.then((authTokenVal: string) => { + authToken = authTokenVal; + }); + return authTokenPromise; +} + +export function getAuthenticationToken(): string | undefined { + return authToken; +} diff --git a/packages/performance/src/services/initialization_service.test.ts b/packages/performance/src/services/initialization_service.test.ts new file mode 100644 index 00000000000..bdeb8fa77db --- /dev/null +++ b/packages/performance/src/services/initialization_service.test.ts @@ -0,0 +1,64 @@ +/** + * @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 { stub } from 'sinon'; +import { expect } from 'chai'; +import { + getInitializationPromise, + isPerfInitialized +} from './initialization_service'; +import { setupApi } from './api_service'; +import { SettingsService } from './settings_service'; +import { FirebaseApp } from '@firebase/app-types'; +import '../../test/setup'; + +describe('Firebase Perofmrance > initialization_service', () => { + const IID = 'fid'; + const AUTH_TOKEN = 'authToken'; + const getId = stub(); + const getToken = stub(); + + const mockWindow = { ...self }; + mockWindow.document = { ...mockWindow.document, readyState: 'complete' }; + + beforeEach(() => { + SettingsService.prototype.firebaseAppInstance = ({ + installations: () => ({ getId, getToken }) + } as unknown) as FirebaseApp; + + stub(self, 'fetch').resolves(new Response('{}')); + mockWindow.localStorage = { ...mockWindow.localStorage, setItem: stub() }; + + setupApi(mockWindow); + }); + + it('changes initialization status after initialization is done', async () => { + getId.resolves(IID); + getToken.resolves(AUTH_TOKEN); + await getInitializationPromise(); + + expect(isPerfInitialized()).to.be.true; + }); + + it('returns initilization as not done before promise is resolved', async () => { + getId.resolves(IID); + getToken.resolves(AUTH_TOKEN); + getInitializationPromise(); + + expect(isPerfInitialized()).to.be.false; + }); +}); diff --git a/packages/performance/src/services/initialization_service.ts b/packages/performance/src/services/initialization_service.ts new file mode 100644 index 00000000000..66cebf112ea --- /dev/null +++ b/packages/performance/src/services/initialization_service.ts @@ -0,0 +1,77 @@ +/** + * @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 { getIidPromise } from './iid_service'; +import { getConfig } from './remote_config_service'; +import { Api } from './api_service'; + +const enum InitializationStatus { + notInitialized = 1, + initializationPending, + initialized +} + +let initializationStatus = InitializationStatus.notInitialized; + +let initializationPromise: Promise | undefined; + +export function getInitializationPromise(): Promise { + initializationStatus = InitializationStatus.initializationPending; + + initializationPromise = initializationPromise || initializePerf(); + + return initializationPromise; +} + +export function isPerfInitialized(): boolean { + return initializationStatus === InitializationStatus.initialized; +} + +function initializePerf(): Promise { + return getDocumentReadyComplete() + .then(() => getIidPromise()) + .then(iid => getConfig(iid)) + .then( + () => changeInitializationStatus(), + () => changeInitializationStatus() + ); +} + +/** + * Returns a promise which resolves whenever the document readystate is complete or + * immediately if it is called after page load complete. + */ +function getDocumentReadyComplete(): Promise { + const document = Api.getInstance().document; + return new Promise(resolve => { + if (document && document.readyState !== 'complete') { + const handler = () => { + if (document.readyState === 'complete') { + document.removeEventListener('readystatechange', handler); + resolve(); + } + }; + document.addEventListener('readystatechange', handler); + } else { + resolve(); + } + }); +} + +function changeInitializationStatus(): void { + initializationStatus = InitializationStatus.initialized; +} diff --git a/packages/performance/src/services/oob_resources_service.test.ts b/packages/performance/src/services/oob_resources_service.test.ts new file mode 100644 index 00000000000..5343f340e2b --- /dev/null +++ b/packages/performance/src/services/oob_resources_service.test.ts @@ -0,0 +1,195 @@ +/** + * @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 { + spy, + stub, + SinonSpy, + SinonStub, + useFakeTimers, + SinonFakeTimers +} from 'sinon'; +import { expect } from 'chai'; +import { Api, setupApi, EntryType } from './api_service'; +import * as iidService from './iid_service'; +import { setupOobResources } from './oob_resources_service'; +import { createNetworkRequestEntry } from '../resources/network_request'; +import { Trace } from '../resources/trace'; +import '../../test/setup'; + +describe('Firebase Performance > oob_resources_service', () => { + const MOCK_ID = 'idasdfsffe'; + + const NAVIGATION_PERFORMANCE_ENTRY: PerformanceNavigationTiming = { + connectEnd: 2.9499998781830072, + connectStart: 2.9499998781830072, + decodedBodySize: 1519, + domComplete: 186.48499995470047, + domContentLoadedEventEnd: 64.0499999281019, + domContentLoadedEventStart: 62.440000008791685, + domInteractive: 62.42000008933246, + domainLookupEnd: 2.9499998781830072, + domainLookupStart: 2.9499998781830072, + duration: 187.7349999267608, + encodedBodySize: 732, + entryType: 'navigation', + fetchStart: 2.9499998781830072, + initiatorType: 'navigation', + loadEventEnd: 187.7349999267608, + loadEventStart: 187.72999988868833, + name: 'https://test.firebase.com/', + nextHopProtocol: 'h2', + redirectCount: 0, + redirectEnd: 0, + redirectStart: 0, + requestStart: 5.034999921917915, + responseEnd: 9.305000072345138, + responseStart: 8.940000087022781, + secureConnectionStart: 0, + startTime: 0, + transferSize: 1259, + type: 'reload', + unloadEventEnd: 14.870000071823597, + unloadEventStart: 14.870000071823597, + workerStart: 0, + toJSON: () => {} + }; + + const PAINT_PERFORMANCE_ENTRY: PerformanceEntry = { + duration: 0, + entryType: 'paint', + name: 'first-contentful-paint', + startTime: 122.18499998562038, + toJSON: () => {} + }; + + let getIidStub: SinonStub<[], string | undefined>; + let apiGetInstanceSpy: SinonSpy<[], Api>; + let getEntriesByTypeStub: SinonStub<[EntryType], PerformanceEntry[]>; + let setupObserverStub: SinonStub< + [EntryType, (entry: PerformanceEntry) => void], + void + >; + let createOobTraceStub: SinonStub< + [PerformanceNavigationTiming[], PerformanceEntry[], (number | undefined)?], + void + >; + let clock: SinonFakeTimers; + + setupApi(self); + + beforeEach(() => { + getIidStub = stub(iidService, 'getIid'); + apiGetInstanceSpy = spy(Api, 'getInstance'); + clock = useFakeTimers(); + getEntriesByTypeStub = stub(Api.prototype, 'getEntriesByType').callsFake( + entry => { + if (entry === 'navigation') return [NAVIGATION_PERFORMANCE_ENTRY]; + return [PAINT_PERFORMANCE_ENTRY]; + } + ); + setupObserverStub = stub(Api.prototype, 'setupObserver'); + createOobTraceStub = stub(Trace, 'createOobTrace'); + }); + + afterEach(() => { + clock.restore(); + }); + + describe('setupOobResources', () => { + it('does not start if there is no iid', () => { + getIidStub.returns(undefined); + setupOobResources(); + + expect(apiGetInstanceSpy).not.to.be.called; + }); + + it('sets up network request collection', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(); + clock.tick(1); + + expect(apiGetInstanceSpy).to.be.called; + expect(getEntriesByTypeStub).to.be.calledWith('resource'); + expect(setupObserverStub).to.be.calledWithExactly( + 'resource', + createNetworkRequestEntry + ); + }); + + it('sets up page load trace collection', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(); + clock.tick(1); + + expect(apiGetInstanceSpy).to.be.called; + expect(getEntriesByTypeStub).to.be.calledWith('navigation'); + expect(getEntriesByTypeStub).to.be.calledWith('paint'); + expect(createOobTraceStub).to.be.calledWithExactly( + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY] + ); + }); + + it('waits for first input delay if polyfill is available', () => { + getIidStub.returns(MOCK_ID); + const api = Api.getInstance(); + api.onFirstInputDelay = stub(); + setupOobResources(); + clock.tick(1); + + expect(api.onFirstInputDelay).to.be.called; + expect(createOobTraceStub).not.to.be.called; + clock.tick(5000); + expect(createOobTraceStub).to.be.calledWithExactly( + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY] + ); + }); + + it('logs first input delay if polyfill is available and callback is called', () => { + getIidStub.returns(MOCK_ID); + const api = Api.getInstance(); + const FIRST_INPUT_DELAY = 123; + // Underscore is to avoid compiler comlaining about variable being declared but not used. + type FirstInputDelayCallback = (firstInputDelay: number) => void; + let firstInputDelayCallback: FirstInputDelayCallback = () => {}; + api.onFirstInputDelay = (cb: FirstInputDelayCallback) => { + firstInputDelayCallback = cb; + }; + setupOobResources(); + clock.tick(1); + firstInputDelayCallback(FIRST_INPUT_DELAY); + + expect(createOobTraceStub).to.be.calledWithExactly( + [NAVIGATION_PERFORMANCE_ENTRY], + [PAINT_PERFORMANCE_ENTRY], + FIRST_INPUT_DELAY + ); + }); + + it('sets up user timing traces', () => { + getIidStub.returns(MOCK_ID); + setupOobResources(); + clock.tick(1); + + expect(apiGetInstanceSpy).to.be.called; + expect(getEntriesByTypeStub).to.be.calledWith('measure'); + expect(setupObserverStub).to.be.calledWith('measure'); + }); + }); +}); diff --git a/packages/performance/src/services/oob_resources_service.ts b/packages/performance/src/services/oob_resources_service.ts new file mode 100644 index 00000000000..d6beddb8b1c --- /dev/null +++ b/packages/performance/src/services/oob_resources_service.ts @@ -0,0 +1,89 @@ +/** + * @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 { Api } from './api_service'; +import { Trace } from '../resources/trace'; +import { createNetworkRequestEntry } from '../resources/network_request'; +import { TRACE_MEASURE_PREFIX } from '../constants'; +import { getIid } from './iid_service'; + +const FID_WAIT_TIME_MS = 5000; + +export function setupOobResources(): void { + // Do not initialize unless iid is available. + if (!getIid()) return; + // The load event might not have fired yet, and that means performance navigation timing + // object has a duration of 0. The setup should run after all current tasks in js queue. + setTimeout(() => setupOobTraces(), 0); + setTimeout(() => setupNetworkRequests(), 0); + setTimeout(() => setupUserTimingTraces(), 0); +} + +function setupNetworkRequests(): void { + const api = Api.getInstance(); + const resources = api.getEntriesByType('resource'); + for (const resource of resources) { + createNetworkRequestEntry(resource); + } + api.setupObserver('resource', createNetworkRequestEntry); +} + +function setupOobTraces(): void { + const api = Api.getInstance(); + const navigationTimings = api.getEntriesByType( + 'navigation' + ) as PerformanceNavigationTiming[]; + const paintTimings = api.getEntriesByType('paint'); + // If First Input Desly polyfill is added to the page, report the fid value. + // https://github.com/GoogleChromeLabs/first-input-delay + if (api.onFirstInputDelay) { + // If the fid call back is not called for certain time, continue without it. + let timeoutId: any = setTimeout(() => { + Trace.createOobTrace(navigationTimings, paintTimings); + timeoutId = undefined; + }, FID_WAIT_TIME_MS); + api.onFirstInputDelay((fid: number) => { + if (timeoutId) { + clearTimeout(timeoutId); + Trace.createOobTrace(navigationTimings, paintTimings, fid); + } + }); + } else { + Trace.createOobTrace(navigationTimings, paintTimings); + } +} + +function setupUserTimingTraces(): void { + const api = Api.getInstance(); + // Run through the measure performance entries collected up to this point. + const measures = api.getEntriesByType('measure'); + for (const measure of measures) { + createUserTimingTrace(measure); + } + // Setup an observer to capture the measures from this point on. + api.setupObserver('measure', createUserTimingTrace); +} + +function createUserTimingTrace(measure: PerformanceEntry): void { + const measureName = measure.name; + // Do not create a trace, if the user timing marks and measures are created by the sdk itself. + if ( + measureName.substring(0, TRACE_MEASURE_PREFIX.length) === + TRACE_MEASURE_PREFIX + ) + return; + Trace.createUserTimingTrace(measureName); +} diff --git a/packages/performance/src/services/perf_logger.test.ts b/packages/performance/src/services/perf_logger.test.ts new file mode 100644 index 00000000000..40d4c59f610 --- /dev/null +++ b/packages/performance/src/services/perf_logger.test.ts @@ -0,0 +1,162 @@ +/** + * @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 { stub, SinonStub, useFakeTimers, SinonFakeTimers } from 'sinon'; +import { Trace } from '../resources/trace'; +import { LogHandler, Logger, LogLevel } from '@firebase/logger'; +import * as ccService from './cc_service'; +import * as iidService from './iid_service'; +import { expect } from 'chai'; +import { Api, setupApi } from './api_service'; +import { SettingsService } from './settings_service'; +import { FirebaseApp } from '@firebase/app-types'; +import * as initializationService from './initialization_service'; +import * as attributeUtils from '../utils/attributes_utils'; +import { createNetworkRequestEntry } from '../resources/network_request'; +import '../../test/setup'; + +describe('Performance Monitoring > perf_logger', () => { + const IID = 'idasdfsffe'; + const PAGE_URL = 'http://mock-page.com'; + const APP_ID = '1:123:web:2er'; + const VISIBILITY_STATE = 3; + const EFFECTIVE_CONNECTION_TYPE = 2; + const SERVICE_WORKER_STATUS = 3; + const TIME_ORIGIN = 1556512199893.9033; + + let addToQueueStub: SinonStub< + Array<{ message: string; eventTime: number }>, + void + >; + let getIidStub: SinonStub<[], string | undefined>; + let clock: SinonFakeTimers; + + function mockCcHandler(serializer: (...args: any[]) => string): LogHandler { + return (_loggerInstance: Logger, _level: LogLevel, ...args) => { + const message = serializer(...args); + addToQueueStub({ + message, + eventTime: Date.now() + }); + }; + } + + setupApi(self); + + beforeEach(() => { + getIidStub = stub(iidService, 'getIid'); + addToQueueStub = stub(); + stub(ccService, 'ccHandler').callsFake(mockCcHandler); + stub(Api.prototype, 'getUrl').returns(PAGE_URL); + stub(Api.prototype, 'getTimeOrigin').returns(TIME_ORIGIN); + stub(initializationService, 'isPerfInitialized').returns(true); + stub(attributeUtils, 'getVisibilityState').returns(VISIBILITY_STATE); + stub(attributeUtils, 'getEffectiveConnectionType').returns( + EFFECTIVE_CONNECTION_TYPE + ); + stub(attributeUtils, 'getServiceWorkerStatus').returns( + SERVICE_WORKER_STATUS + ); + SettingsService.prototype.firebaseAppInstance = ({ + options: { appId: APP_ID } + } as unknown) as FirebaseApp; + clock = useFakeTimers(); + }); + + describe('logTrace', () => { + it('creates, serializes and sends a trace to cc service', () => { + const TRACE_NAME = 'testTrace'; + const START_TIME = 12345; + const DURATION = 321; + const EXPECTED_TRACE_MESSAGE = `{"application_info":{"google_app_id":"${APP_ID}",\ +"app_instance_id":"${IID}","web_app_info":{"sdk_version":"",\ +"page_url":"${PAGE_URL}","service_worker_status":${SERVICE_WORKER_STATUS},\ +"visibility_state":${VISIBILITY_STATE},"effective_connection_type":${EFFECTIVE_CONNECTION_TYPE}},\ +"application_process_state":0},"trace_metric":{"name":"${TRACE_NAME}","is_auto":false,\ +"client_start_time_us":${START_TIME * 1000},"duration_us":${DURATION * 1000}}}`; + getIidStub.returns(IID); + SettingsService.getInstance().loggingEnabled = true; + SettingsService.getInstance().logTraceAfterSampling = true; + const trace = new Trace(TRACE_NAME); + trace.record(START_TIME, DURATION); + clock.tick(1); + + expect(addToQueueStub).to.be.called; + expect(addToQueueStub.getCall(0).args[0].message).to.be.equal( + EXPECTED_TRACE_MESSAGE + ); + }); + }); + + describe('logNetworkRequest', () => { + it('creates, serializes and sends a network request to cc service', () => { + const RESOURCE_PERFORMANCE_ENTRY: PerformanceResourceTiming = { + connectEnd: 0, + connectStart: 0, + decodedBodySize: 0, + domainLookupEnd: 0, + domainLookupStart: 0, + duration: 39.610000094398856, + encodedBodySize: 0, + entryType: 'resource', + fetchStart: 5645.689999917522, + initiatorType: 'fetch', + name: 'https://test.com/abc', + nextHopProtocol: 'http/2+quic/43', + redirectEnd: 0, + redirectStart: 0, + requestStart: 0, + responseEnd: 5685.300000011921, + responseStart: 0, + secureConnectionStart: 0, + startTime: 5645.689999917522, + transferSize: 0, + workerStart: 0, + toJSON: () => {} + }; + const START_TIME = Math.floor( + (TIME_ORIGIN + RESOURCE_PERFORMANCE_ENTRY.startTime) * 1000 + ); + const TIME_TO_RESPONSE_COMPLETED = Math.floor( + (RESOURCE_PERFORMANCE_ENTRY.responseEnd - + RESOURCE_PERFORMANCE_ENTRY.startTime) * + 1000 + ); + const EXPECTED_NETWORK_MESSAGE = `{"application_info":{"google_app_id":"${APP_ID}",\ +"app_instance_id":"${IID}","web_app_info":{"sdk_version":"",\ +"page_url":"${PAGE_URL}","service_worker_status":${SERVICE_WORKER_STATUS},\ +"visibility_state":${VISIBILITY_STATE},"effective_connection_type":${EFFECTIVE_CONNECTION_TYPE}},\ +"application_process_state":0},\ +"network_request_metric":{"url":"${RESOURCE_PERFORMANCE_ENTRY.name}",\ +"http_method":1,"http_response_code":200,\ +"response_payload_bytes":${RESOURCE_PERFORMANCE_ENTRY.transferSize},\ +"client_start_time_us":${START_TIME},\ +"time_to_response_completed_us":${TIME_TO_RESPONSE_COMPLETED}}}`; + getIidStub.returns(IID); + SettingsService.getInstance().loggingEnabled = true; + SettingsService.getInstance().logNetworkAfterSampling = true; + // Calls logNetworkRequest under the hood. + createNetworkRequestEntry(RESOURCE_PERFORMANCE_ENTRY); + clock.tick(1); + + expect(addToQueueStub).to.be.called; + expect(addToQueueStub.getCall(0).args[0].message).to.be.equal( + EXPECTED_NETWORK_MESSAGE + ); + }); + }); +}); diff --git a/packages/performance/src/services/perf_logger.ts b/packages/performance/src/services/perf_logger.ts new file mode 100644 index 00000000000..b2b46a24441 --- /dev/null +++ b/packages/performance/src/services/perf_logger.ts @@ -0,0 +1,216 @@ +/** + * @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 { getIid } from './iid_service'; +import { NetworkRequest } from '../resources/network_request'; +import { Trace } from '../resources/trace'; +import { Api } from './api_service'; +import { SettingsService } from './settings_service'; +import { + getServiceWorkerStatus, + getVisibilityState, + VisibilityState, + getEffectiveConnectionType +} from '../utils/attributes_utils'; +import { + isPerfInitialized, + getInitializationPromise +} from './initialization_service'; +import { Logger } from '@firebase/logger'; +import { ccHandler } from './cc_service'; +import { SDK_VERSION } from '../constants'; + +const enum ResourceType { + NetworkRequest, + Trace +} + +interface ApplicationInfo { + google_app_id: string; + app_instance_id?: string; + web_app_info: WebAppInfo; + application_process_state: number; +} + +interface WebAppInfo { + sdk_version: string; + page_url: string; + service_worker_status: number; + visibility_state: number; + effective_connection_type: number; +} + +interface PerfNetworkLog { + application_info: ApplicationInfo; + network_request_metric: NetworkRequestMetric; +} + +interface PerfTraceLog { + application_info: ApplicationInfo; + trace_metric: TraceMetric; +} + +interface NetworkRequestMetric { + url: string; + http_method: number; + http_response_code: number; + response_payload_bytes?: number; + client_start_time_us?: number; + time_to_response_initiated_us?: number; + time_to_response_completed_us?: number; +} + +interface TraceMetric { + name: string; + is_auto: boolean; + client_start_time_us: number; + duration_us: number; + counters?: Array<{ key: string; value: number }>; + custom_attributes?: Array<{ key: string; value: string }>; +} + +let logger: Logger | undefined; +// This method is not called before initialization. +function getLogger(): Logger { + if (logger) return logger; + const ccLogger = ccHandler(serializer); + logger = new Logger('@firebase/performance/cc'); + logger.logHandler = ccLogger; + return logger; +} + +export function logTrace(trace: Trace): void { + const settingsService = SettingsService.getInstance(); + // Do not log if trace is auto generated and instrumentation is disabled. + if (!settingsService.instrumentationEnabled && trace.isAuto) return; + // Do not log if trace is custom and data collection is disabled. + if (!settingsService.dataCollectionEnabled && !trace.isAuto) return; + // Only log the page load auto traces if page is visible. + if (trace.isAuto && getVisibilityState() !== VisibilityState.VISIBLE) return; + + if (!settingsService.loggingEnabled || !settingsService.logTraceAfterSampling) + return; + + if (isPerfInitialized()) { + sendTraceLog(trace); + } else { + // Custom traces can be used before the initialization but logging + // should wait until after. + getInitializationPromise().then( + () => sendTraceLog(trace), + () => sendTraceLog(trace) + ); + } +} + +function sendTraceLog(trace: Trace): void { + if (getIid()) { + setTimeout(() => getLogger().log(trace, ResourceType.Trace), 0); + } +} + +export function logNetworkRequest(networkRequest: NetworkRequest): void { + const settingsService = SettingsService.getInstance(); + // Do not log network requests if instrumentation is disabled. + if (!settingsService.instrumentationEnabled) return; + // Do not log the js sdk's call to cc service to avoid unnecessary cycle. + if (networkRequest.url === settingsService.logEndPointUrl.split('?')[0]) + return; + + if ( + !settingsService.loggingEnabled || + !settingsService.logNetworkAfterSampling + ) + return; + + setTimeout( + () => getLogger().log(networkRequest, ResourceType.NetworkRequest), + 0 + ); +} + +function serializer(resource: {}, resourceType: ResourceType): string { + if (resourceType === ResourceType.NetworkRequest) { + return serializeNetworkRequest(resource as NetworkRequest); + } + return serializeTrace(resource as Trace); +} + +function serializeNetworkRequest(networkRequest: NetworkRequest): string { + const networkRequestMetric: NetworkRequestMetric = { + url: networkRequest.url, + http_method: 1, + http_response_code: 200, + response_payload_bytes: networkRequest.responsePayloadBytes, + client_start_time_us: networkRequest.startTimeUs, + time_to_response_initiated_us: networkRequest.timeToResponseInitiatedUs, + time_to_response_completed_us: networkRequest.timeToResponseCompletedUs + }; + const perfMetric: PerfNetworkLog = { + application_info: getApplicationInfo(), + network_request_metric: networkRequestMetric + }; + return JSON.stringify(perfMetric); +} + +function serializeTrace(trace: Trace): string { + const traceMetric: TraceMetric = { + name: trace.name, + is_auto: trace.isAuto, + client_start_time_us: trace.startTimeUs, + duration_us: trace.durationUs + }; + + if (Object.keys(trace.counters).length !== 0) { + traceMetric.counters = convertToKeyValueArray(trace.counters); + } + const customAttributes = trace.getAttributes(); + if (Object.keys(customAttributes).length !== 0) { + traceMetric.custom_attributes = convertToKeyValueArray(customAttributes); + } + + const perfMetric: PerfTraceLog = { + application_info: getApplicationInfo(), + trace_metric: traceMetric + }; + return JSON.stringify(perfMetric); +} + +function getApplicationInfo(): ApplicationInfo { + return { + google_app_id: SettingsService.getInstance().getAppId(), + app_instance_id: getIid(), + web_app_info: { + sdk_version: SDK_VERSION, + page_url: Api.getInstance().getUrl(), + service_worker_status: getServiceWorkerStatus(), + visibility_state: getVisibilityState(), + effective_connection_type: getEffectiveConnectionType() + }, + application_process_state: 0 + }; +} + +function convertToKeyValueArray(obj: { + [key: string]: T; +}): Array<{ + key: string; + value: T; +}> { + const keys = Object.keys(obj); + return keys.map(key => ({ key, value: obj[key] })); +} diff --git a/packages/performance/src/services/remote_config_service.test.ts b/packages/performance/src/services/remote_config_service.test.ts new file mode 100644 index 00000000000..3a2d86eabbb --- /dev/null +++ b/packages/performance/src/services/remote_config_service.test.ts @@ -0,0 +1,180 @@ +/** + * @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 { stub, SinonStub, useFakeTimers, SinonFakeTimers } from 'sinon'; +import { expect } from 'chai'; +import { SettingsService } from './settings_service'; +import { CONFIG_EXPIRY_LOCAL_STORAGE_KEY } from '../constants'; +import { setupApi } from './api_service'; +import * as iidService from './iid_service'; +import { getConfig } from './remote_config_service'; +import { FirebaseApp } from '@firebase/app-types'; +import '../../test/setup'; + +describe('Performance Monitoring > remote_config_service', () => { + const IID = 'asd123'; + const AUTH_TOKEN = 'auth_token'; + const LOG_URL = 'https://firebaselogging.test.com'; + const LOG_SOURCE = 2; + const NETWORK_SAMPLIG_RATE = 0.25; + const TRACE_SAMPLING_RATE = 0.5; + const GLOBAL_CLOCK_NOW = 1556524895326; + const STRINGIFIED_CONFIG = `{"entries":{"fpr_enabled":"true",\ +"fpr_log_endpoint_url":"https://firebaselogging.test.com",\ +"fpr_log_source":"2","fpr_vc_network_request_sampling_rate":"0.250000",\ +"fpr_vc_session_sampling_rate":"0.250000","fpr_vc_trace_sampling_rate":"0.500000"},\ +"state":"UPDATE"}`; + const PROJECT_ID = 'project1'; + const APP_ID = '1:23r:web:fewq'; + const API_KEY = 'asdfghjk'; + + let fetchStub: SinonStub<[RequestInfo, RequestInit?], Promise>; + let storageGetItemStub: SinonStub<[string], string | null>; + let clock: SinonFakeTimers; + + setupApi(self); + + function storageGetItemFakeFactory(expiry: string, config: string) { + return (key: string) => { + if (key === CONFIG_EXPIRY_LOCAL_STORAGE_KEY) { + return expiry; + } + return config; + }; + } + + function resetSettingsService() { + const settingsService = SettingsService.getInstance(); + settingsService.logSource = 462; + settingsService.loggingEnabled = false; + settingsService.networkRequestsSamplingRate = 1; + settingsService.tracesSamplingRate = 1; + } + + beforeEach(() => { + fetchStub = stub(self, 'fetch'); + storageGetItemStub = stub(self.localStorage, 'getItem'); + stub(iidService, 'getAuthTokenPromise').returns( + Promise.resolve(AUTH_TOKEN) + ); + clock = useFakeTimers(GLOBAL_CLOCK_NOW); + SettingsService.prototype.firebaseAppInstance = ({ + options: { projectId: PROJECT_ID, appId: APP_ID, apiKey: API_KEY } + } as unknown) as FirebaseApp; + }); + + afterEach(() => { + resetSettingsService(); + clock.restore(); + }); + + describe('getConfig', () => { + it('gets the config from the local storage if available and valid', async () => { + // After global clock. Config not expired. + const EXPIRY_LOCAL_STORAGE_VALUE = '1556524895330'; + storageGetItemStub.callsFake( + storageGetItemFakeFactory( + EXPIRY_LOCAL_STORAGE_VALUE, + STRINGIFIED_CONFIG + ) + ); + await getConfig(IID); + + expect(storageGetItemStub).to.be.called; + expect(SettingsService.getInstance().loggingEnabled).to.be.true; + expect(SettingsService.getInstance().logEndPointUrl).to.equal(LOG_URL); + expect(SettingsService.getInstance().logSource).to.equal(LOG_SOURCE); + expect( + SettingsService.getInstance().networkRequestsSamplingRate + ).to.equal(NETWORK_SAMPLIG_RATE); + expect(SettingsService.getInstance().tracesSamplingRate).to.equal( + TRACE_SAMPLING_RATE + ); + }); + + it('does not call remote config if a valid config is in local storage', async () => { + // After global clock. Config not expired. + const EXPIRY_LOCAL_STORAGE_VALUE = '1556524895330'; + storageGetItemStub.callsFake( + storageGetItemFakeFactory( + EXPIRY_LOCAL_STORAGE_VALUE, + STRINGIFIED_CONFIG + ) + ); + await getConfig(IID); + + expect(fetchStub).not.to.be.called; + }); + + it('gets the config from RC if local version is not valid', async () => { + // Expired local config. + const EXPIRY_LOCAL_STORAGE_VALUE = '1556524895320'; + storageGetItemStub.callsFake( + storageGetItemFakeFactory( + EXPIRY_LOCAL_STORAGE_VALUE, + 'not a valid config and should not be used' + ) + ); + fetchStub.resolves(new Response(STRINGIFIED_CONFIG)); + await getConfig(IID); + + expect(storageGetItemStub).to.be.calledOnce; + expect(SettingsService.getInstance().loggingEnabled).to.be.true; + expect(SettingsService.getInstance().logEndPointUrl).to.equal(LOG_URL); + expect(SettingsService.getInstance().logSource).to.equal(LOG_SOURCE); + expect( + SettingsService.getInstance().networkRequestsSamplingRate + ).to.equal(NETWORK_SAMPLIG_RATE); + expect(SettingsService.getInstance().tracesSamplingRate).to.equal( + TRACE_SAMPLING_RATE + ); + }); + + it('does not change the default config if call to RC fails', async () => { + // Expired local config. + const EXPIRY_LOCAL_STORAGE_VALUE = '1556524895320'; + storageGetItemStub.callsFake( + storageGetItemFakeFactory( + EXPIRY_LOCAL_STORAGE_VALUE, + 'not a valid config and should not be used' + ) + ); + fetchStub.rejects(); + await getConfig(IID); + + expect(SettingsService.getInstance().loggingEnabled).to.equal(false); + }); + + it('uses secondary configs if the response does not have all the fields', async () => { + // Expired local config. + const EXPIRY_LOCAL_STORAGE_VALUE = '1556524895320'; + storageGetItemStub.callsFake( + storageGetItemFakeFactory( + EXPIRY_LOCAL_STORAGE_VALUE, + 'not a valid config and should not be used' + ) + ); + const STRINGIFIED_PARTIAL_CONFIG = `{"entries":{\ + "fpr_vc_network_request_sampling_rate":"0.250000",\ + "fpr_vc_session_sampling_rate":"0.250000","fpr_vc_trace_sampling_rate":"0.500000"},\ + "state":"UPDATE"}`; + fetchStub.resolves(new Response(STRINGIFIED_PARTIAL_CONFIG)); + await getConfig(IID); + + expect(SettingsService.getInstance().loggingEnabled).to.be.true; + }); + }); +}); diff --git a/packages/performance/src/services/remote_config_service.ts b/packages/performance/src/services/remote_config_service.ts new file mode 100644 index 00000000000..c44751940a0 --- /dev/null +++ b/packages/performance/src/services/remote_config_service.ts @@ -0,0 +1,204 @@ +/** + * @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 { SettingsService } from './settings_service'; +import { + SDK_VERSION, + CONFIG_LOCAL_STORAGE_KEY, + CONFIG_EXPIRY_LOCAL_STORAGE_KEY +} from '../constants'; +import { Api } from './api_service'; +import { getAuthTokenPromise } from './iid_service'; +import { consoleLogger } from '../utils/console_logger'; +import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; + +const REMOTE_CONFIG_SDK_VERSION = '0.0.1'; + +interface SecondaryConfig { + loggingEnabled?: boolean; + logEndPointUrl?: string; + logSource?: number; + tracesSamplingRate?: number; + networkRequestsSamplingRate?: number; +} + +// These values will be used if the remote config object is successfully +// retrieved, but the template does not have these fields. +const SECONDARY_CONFIGS: SecondaryConfig = { + loggingEnabled: true +}; + +interface RemoteConfigTemplate { + fpr_enabled?: string; + fpr_log_source?: string; + fpr_log_endpoint_url?: string; + fpr_vc_network_request_sampling_rate?: string; + fpr_vc_trace_sampling_rate?: string; + fpr_vc_session_sampling_rate?: string; +} + +interface RemoteConfigResponse { + entries?: RemoteConfigTemplate; + state?: string; +} + +const FIS_AUTH_PREFIX = 'FIREBASE_INSTALLATIONS_AUTH'; + +export function getConfig(iid: string): Promise { + const config = getStoredConfig(); + if (config) { + processConfig(config); + return Promise.resolve(); + } + + return getRemoteConfig(iid) + .then(config => processConfig(config)) + .then( + config => storeConfig(config), + /** Do nothing for error, use defaults set in settings service. */ () => {} + ); +} + +function getStoredConfig(): RemoteConfigResponse | undefined { + const localStorage = Api.getInstance().localStorage; + const expiryString = localStorage.getItem(CONFIG_EXPIRY_LOCAL_STORAGE_KEY); + if (!expiryString || !configValid(expiryString)) return; + + const configStringified = localStorage.getItem(CONFIG_LOCAL_STORAGE_KEY); + if (!configStringified) return; + try { + const configResponse: RemoteConfigResponse = JSON.parse(configStringified); + return configResponse; + } catch { + return; + } +} + +function storeConfig(config: RemoteConfigResponse | undefined): void { + if (!config) return; + const localStorage = Api.getInstance().localStorage; + localStorage.setItem(CONFIG_LOCAL_STORAGE_KEY, JSON.stringify(config)); + localStorage.setItem( + CONFIG_EXPIRY_LOCAL_STORAGE_KEY, + String( + Date.now() + + SettingsService.getInstance().configTimeToLive * 60 * 60 * 1000 + ) + ); +} + +const COULD_NOT_GET_CONFIG_MSG = + 'Could not fetch config, will use default configs'; + +function getRemoteConfig( + iid: string +): Promise { + // Perf needs auth token only to retrieve remote config. + return getAuthTokenPromise() + .then(authToken => { + const projectId = SettingsService.getInstance().getProjectId(); + const configEndPoint = `https://firebaseremoteconfig.googleapis.com/v1/projects/${projectId}/namespaces/fireperf:fetch?key=${SettingsService.getInstance().getApiKey()}`; + const request = new Request(configEndPoint, { + method: 'POST', + headers: { + Authorization: `${FIS_AUTH_PREFIX} ${authToken}` + }, + body: JSON.stringify({ + app_instance_id: iid, + app_instance_id_token: authToken, + app_id: SettingsService.getInstance().getAppId(), + app_version: SDK_VERSION, + sdk_version: REMOTE_CONFIG_SDK_VERSION + }) + }); + return fetch(request).then(response => { + if (response.ok) { + return response.json() as RemoteConfigResponse; + } + // In case response is not ok. This will be caught by catch. + throw ERROR_FACTORY.create(ErrorCode.RC_NOT_OK); + }); + }) + .catch(() => { + consoleLogger.info(COULD_NOT_GET_CONFIG_MSG); + return undefined; + }); +} + +/** + * Processes config coming either from calling RC or from local storage. + * This method only runs if call is successful or config in storage + * is valie. + */ +function processConfig( + config: RemoteConfigResponse | undefined +): RemoteConfigResponse | undefined { + if (!config || !config.entries) return config; + const settingsServiceInstance = SettingsService.getInstance(); + const entries = config.entries; + if (entries.fpr_enabled !== undefined) { + // TODO: Change the assignment of loggingEnabled once the received type is known. + settingsServiceInstance.loggingEnabled = + String(entries.fpr_enabled) === 'true'; + } else if (SECONDARY_CONFIGS.loggingEnabled !== undefined) { + // Config retrieved successfully, but there is no fpr_enabled in template. + // Use secondary configs value. + settingsServiceInstance.loggingEnabled = SECONDARY_CONFIGS.loggingEnabled; + } + if (entries.fpr_log_source) { + settingsServiceInstance.logSource = Number(entries.fpr_log_source); + } else if (SECONDARY_CONFIGS.logSource) { + settingsServiceInstance.logSource = SECONDARY_CONFIGS.logSource; + } + if (entries.fpr_log_endpoint_url) { + settingsServiceInstance.logEndPointUrl = entries.fpr_log_endpoint_url; + } else if (SECONDARY_CONFIGS.logEndPointUrl) { + settingsServiceInstance.logEndPointUrl = SECONDARY_CONFIGS.logEndPointUrl; + } + if (entries.fpr_vc_network_request_sampling_rate !== undefined) { + settingsServiceInstance.networkRequestsSamplingRate = Number( + entries.fpr_vc_network_request_sampling_rate + ); + } else if (SECONDARY_CONFIGS.networkRequestsSamplingRate !== undefined) { + settingsServiceInstance.networkRequestsSamplingRate = + SECONDARY_CONFIGS.networkRequestsSamplingRate; + } + if (entries.fpr_vc_trace_sampling_rate !== undefined) { + settingsServiceInstance.tracesSamplingRate = Number( + entries.fpr_vc_trace_sampling_rate + ); + } else if (SECONDARY_CONFIGS.tracesSamplingRate !== undefined) { + settingsServiceInstance.tracesSamplingRate = + SECONDARY_CONFIGS.tracesSamplingRate; + } + // Set the per session trace and network logging flags. + settingsServiceInstance.logTraceAfterSampling = shouldLogAfterSampling( + settingsServiceInstance.tracesSamplingRate + ); + settingsServiceInstance.logNetworkAfterSampling = shouldLogAfterSampling( + settingsServiceInstance.networkRequestsSamplingRate + ); + return config; +} + +function configValid(expiry: string): boolean { + return Number(expiry) > Date.now(); +} + +function shouldLogAfterSampling(samplingRate: number): boolean { + return Math.random() <= samplingRate; +} diff --git a/packages/performance/src/services/settings_service.ts b/packages/performance/src/services/settings_service.ts new file mode 100644 index 00000000000..4e0fafa9572 --- /dev/null +++ b/packages/performance/src/services/settings_service.ts @@ -0,0 +1,88 @@ +/** + * @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 { ERROR_FACTORY, ErrorCode } from '../utils/errors'; + +let settingsServiceInstance: SettingsService | undefined; + +export class SettingsService { + // The variable which controls logging of automatic traces and HTTP/S network monitoring. + instrumentationEnabled = true; + + // The variable which controls logging of custom traces. + dataCollectionEnabled = true; + + // Configuration flags set through remote config. + loggingEnabled = false; + // Sampling rate between 0 and 1. + tracesSamplingRate = 1; + networkRequestsSamplingRate = 1; + // Address of logging service. + logEndPointUrl = + 'https://firebaselogging.googleapis.com/v0cc/log?format=json_proto'; + logSource = 462; + + // Flags which control per session logging of traces and network requests. + logTraceAfterSampling = false; + logNetworkAfterSampling = false; + + // TTL of config retrieved from remote config in hours. + configTimeToLive = 12; + + firebaseAppInstance!: FirebaseApp; + + getAppId(): string { + const appId = + this.firebaseAppInstance && + this.firebaseAppInstance.options && + this.firebaseAppInstance.options.appId; + if (!appId) { + throw ERROR_FACTORY.create(ErrorCode.NO_APP_ID); + } + return appId; + } + + getProjectId(): string { + const projectId = + this.firebaseAppInstance && + this.firebaseAppInstance.options && + this.firebaseAppInstance.options.projectId; + if (!projectId) { + throw ERROR_FACTORY.create(ErrorCode.NO_PROJECT_ID); + } + return projectId; + } + + getApiKey(): string { + const apiKey = + this.firebaseAppInstance && + this.firebaseAppInstance.options && + this.firebaseAppInstance.options.apiKey; + if (!apiKey) { + throw ERROR_FACTORY.create(ErrorCode.NO_API_KEY); + } + return apiKey; + } + + static getInstance(): SettingsService { + if (settingsServiceInstance === undefined) { + settingsServiceInstance = new SettingsService(); + } + return settingsServiceInstance; + } +} diff --git a/packages/performance/src/utils/attribute_utils.test.ts b/packages/performance/src/utils/attribute_utils.test.ts new file mode 100644 index 00000000000..b08686a63dd --- /dev/null +++ b/packages/performance/src/utils/attribute_utils.test.ts @@ -0,0 +1,175 @@ +/** + * @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 unknown KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { restore, stub } from 'sinon'; +import { expect } from 'chai'; +import { Api } from '../services/api_service'; + +import { + getVisibilityState, + VisibilityState, + getServiceWorkerStatus, + getEffectiveConnectionType +} from './attributes_utils'; + +import '../../test/setup'; + +describe('Firebase Performance > attribute_utils', () => { + describe('#getServiceWorkerStatus', () => { + it('returns unsupported when service workers unsupported', () => { + stub(Api, 'getInstance').returns(({ + navigator: {} + } as unknown) as Api); + + expect(getServiceWorkerStatus()).to.be.eql(1); + }); + + it('returns controlled when service workers controlled', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + serviceWorker: { + controller: {} + } + } + } as unknown) as Api); + + expect(getServiceWorkerStatus()).to.be.eql(2); + }); + + it('returns uncontrolled when service workers uncontrolled', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + serviceWorker: {} + } + } as unknown) as Api); + + expect(getServiceWorkerStatus()).to.be.eql(3); + }); + }); + + describe('#getVisibilityState', () => { + afterEach(() => { + restore(); + }); + + it('returns visible when document is visible', () => { + stub(Api, 'getInstance').returns(({ + document: { + visibilityState: 'visible' + } + } as unknown) as Api); + expect(getVisibilityState()).to.be.eql(VisibilityState.VISIBLE); + }); + + it('returns hidden when document is hidden', () => { + stub(Api, 'getInstance').returns(({ + document: { + visibilityState: 'hidden' + } + } as unknown) as Api); + expect(getVisibilityState()).to.be.eql(VisibilityState.HIDDEN); + }); + + it('returns prerender when document is prerender', () => { + stub(Api, 'getInstance').returns(({ + document: { + visibilityState: 'prerender' + } + } as unknown) as Api); + expect(getVisibilityState()).to.be.eql(VisibilityState.PRERENDER); + }); + + it('returns unknown when document is unknown', () => { + stub(Api, 'getInstance').returns(({ + document: { + visibilityState: 'unknown' + } + } as unknown) as Api); + expect(getVisibilityState()).to.be.eql(VisibilityState.UNKNOWN); + }); + }); + + describe('#getEffectiveConnectionType', () => { + afterEach(() => { + restore(); + }); + + it('returns EffectiveConnectionType.CONNECTION_SLOW_2G when slow-2g', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: { + effectiveType: 'slow-2g' + } + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(1); + }); + + it('returns EffectiveConnectionType.CONNECTION_2G when 2g', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: { + effectiveType: '2g' + } + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(2); + }); + + it('returns EffectiveConnectionType.CONNECTION_3G when 3g', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: { + effectiveType: '3g' + } + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(3); + }); + + it('returns EffectiveConnectionType.CONNECTION_4G when 4g', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: { + effectiveType: '4g' + } + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(4); + }); + + it('returns EffectiveConnectionType.UNKNOWN when unknown connection type', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: { + effectiveType: '5g' + } + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(0); + }); + + it('returns EffectiveConnectionType.UNKNOWN when no effective type', () => { + stub(Api, 'getInstance').returns(({ + navigator: { + connection: {} + } + } as unknown) as Api); + expect(getEffectiveConnectionType()).to.be.eql(0); + }); + }); +}); diff --git a/packages/performance/src/utils/attributes_utils.ts b/packages/performance/src/utils/attributes_utils.ts new file mode 100644 index 00000000000..a0a236a7246 --- /dev/null +++ b/packages/performance/src/utils/attributes_utils.ts @@ -0,0 +1,89 @@ +/** + * @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 { Api } from '../services/api_service'; + +// The values and orders of the following enums should not be changed. +const enum ServiceWorkerStatus { + UNKNOWN = 0, + UNSUPPORTED = 1, + CONTROLLED = 2, + UNCONTROLLED = 3 +} + +export enum VisibilityState { + UNKNOWN = 0, + VISIBLE = 1, + HIDDEN = 2, + PRERENDER = 3, + UNLOADED = 4 +} + +const enum EffectiveConnectionType { + UNKNOWN = 0, + CONNECTION_SLOW_2G = 1, + CONNECTION_2G = 2, + CONNECTION_3G = 3, + CONNECTION_4G = 4 +} + +export function getServiceWorkerStatus(): ServiceWorkerStatus { + const navigator = Api.getInstance().navigator; + if ('serviceWorker' in navigator) { + if (navigator.serviceWorker.controller) { + return ServiceWorkerStatus.CONTROLLED; + } else { + return ServiceWorkerStatus.UNCONTROLLED; + } + } else { + return ServiceWorkerStatus.UNSUPPORTED; + } +} + +export function getVisibilityState(): VisibilityState { + const document = Api.getInstance().document; + const visibilityState = document.visibilityState; + switch (visibilityState) { + case 'visible': + return VisibilityState.VISIBLE; + case 'hidden': + return VisibilityState.HIDDEN; + case 'prerender': + return VisibilityState.PRERENDER; + default: + return VisibilityState.UNKNOWN; + } +} + +export function getEffectiveConnectionType(): EffectiveConnectionType { + const navigator = Api.getInstance().navigator; + const navigatorConnection = (navigator as any).connection; + const effectiveType = + navigatorConnection && navigatorConnection.effectiveType; + switch (effectiveType) { + case 'slow-2g': + return EffectiveConnectionType.CONNECTION_SLOW_2G; + case '2g': + return EffectiveConnectionType.CONNECTION_2G; + case '3g': + return EffectiveConnectionType.CONNECTION_3G; + case '4g': + return EffectiveConnectionType.CONNECTION_4G; + default: + return EffectiveConnectionType.UNKNOWN; + } +} diff --git a/packages/performance/src/utils/console_logger.ts b/packages/performance/src/utils/console_logger.ts new file mode 100644 index 00000000000..f17419bcda6 --- /dev/null +++ b/packages/performance/src/utils/console_logger.ts @@ -0,0 +1,22 @@ +/** + * @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 { Logger, LogLevel } from '@firebase/logger'; +import { SERVICE_NAME } from '../constants'; + +export const consoleLogger = new Logger(SERVICE_NAME); +consoleLogger.logLevel = LogLevel.INFO; diff --git a/packages/performance/src/utils/errors.ts b/packages/performance/src/utils/errors.ts new file mode 100644 index 00000000000..53322a45833 --- /dev/null +++ b/packages/performance/src/utils/errors.ts @@ -0,0 +1,50 @@ +/** + * @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 } from '@firebase/util'; +import { SERVICE, SERVICE_NAME } from '../constants'; + +export const enum ErrorCode { + TRACE_STARTED_BEFORE = 'trace started', + TRACE_STOPPED_BEFORE = 'trace stopped', + NO_WINDOW = 'no window', + NO_APP_ID = 'no app id', + NO_PROJECT_ID = 'no project id', + NO_API_KEY = 'no api key', + INVALID_CC_LOG = 'invalid cc log', + FB_NOT_DEFAULT = 'FB not default', + RC_NOT_OK = 'RC response not ok' +} + +const ERROR_DESCRIPTION_MAP: { readonly [key in ErrorCode]: string } = { + [ErrorCode.TRACE_STARTED_BEFORE]: 'Trace {$traceName} was started before.', + [ErrorCode.TRACE_STOPPED_BEFORE]: 'Trace {$traceName} is not running.', + [ErrorCode.NO_WINDOW]: 'Window is not available.', + [ErrorCode.NO_APP_ID]: 'App id is not available.', + [ErrorCode.NO_PROJECT_ID]: 'Project id is not available.', + [ErrorCode.NO_API_KEY]: 'Api key is not available.', + [ErrorCode.INVALID_CC_LOG]: 'Attempted to queue invalid cc event', + [ErrorCode.FB_NOT_DEFAULT]: + 'Performance can only start when Firebase app instance is the default one.', + [ErrorCode.RC_NOT_OK]: 'RC response is not ok' +}; + +export const ERROR_FACTORY = new ErrorFactory( + SERVICE, + SERVICE_NAME, + ERROR_DESCRIPTION_MAP +); diff --git a/packages/performance/test/setup.ts b/packages/performance/test/setup.ts new file mode 100644 index 00000000000..90299f9ec55 --- /dev/null +++ b/packages/performance/test/setup.ts @@ -0,0 +1,27 @@ +/** + * @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 { restore } from 'sinon'; +import { use } from 'chai'; +import * as chaiAsPromised from 'chai-as-promised'; +import * as sinonChai from 'sinon-chai'; + +use(chaiAsPromised); +use(sinonChai); +afterEach(() => { + restore(); +}); diff --git a/packages/performance/tsconfig.json b/packages/performance/tsconfig.json new file mode 100644 index 00000000000..d4d0496853f --- /dev/null +++ b/packages/performance/tsconfig.json @@ -0,0 +1,14 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file diff --git a/packages/polyfill/package.json b/packages/polyfill/package.json index 9dd4beee811..bdce097a225 100644 --- a/packages/polyfill/package.json +++ b/packages/polyfill/package.json @@ -12,7 +12,7 @@ "build": "rollup -c", "dev": "rollup -c -w", "test": "echo 'No test suite for polyfills'", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/polyfill/rollup.config.js b/packages/polyfill/rollup.config.js index 2c0bf20329f..dd0f7b5c717 100644 --- a/packages/polyfill/rollup.config.js +++ b/packages/polyfill/rollup.config.js @@ -15,12 +15,13 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; const plugins = [ - typescript({ - typescript: require('typescript') + typescriptPlugin({ + typescript }) ]; diff --git a/packages/rxfire/package.json b/packages/rxfire/package.json index bd63772f9bd..5ea53997e59 100644 --- a/packages/rxfire/package.json +++ b/packages/rxfire/package.json @@ -23,8 +23,8 @@ "scripts": { "build": "rollup -c", "dev": "rollup -c -w", - "prepare": "npm run build", - "test": "npm run test:browser", + "prepare": "yarn build", + "test": "yarn test:browser", "test:browser": "karma start --single-run", "test:browser:debug": "karma start --browsers=Chrome --auto-watch" }, diff --git a/packages/rxfire/rollup.config.js b/packages/rxfire/rollup.config.js index 31365ce123f..1bf83fd9c58 100644 --- a/packages/rxfire/rollup.config.js +++ b/packages/rxfire/rollup.config.js @@ -18,8 +18,9 @@ import { resolve } from 'path'; import resolveModule from 'rollup-plugin-node-resolve'; import commonjs from 'rollup-plugin-commonjs'; -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; import { uglify } from 'rollup-plugin-uglify'; +import typescript from 'typescript'; import pkg from './package.json'; import authPkg from './auth/package.json'; @@ -38,8 +39,8 @@ const pkgsByName = { const plugins = [ resolveModule(), - typescript({ - typescript: require('typescript') + typescriptPlugin({ + typescript }), commonjs() ]; diff --git a/packages/storage/package.json b/packages/storage/package.json index 1ce914fb673..295f1782653 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -5,6 +5,7 @@ "author": "Firebase (https://firebase.google.com/)", "main": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -13,7 +14,7 @@ "dev": "rollup -c -w", "test": "run-p test:browser", "test:browser": "karma start --single-run", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/storage/rollup.config.js b/packages/storage/rollup.config.js index 2c0bf20329f..b3d3a4740f9 100644 --- a/packages/storage/rollup.config.js +++ b/packages/storage/rollup.config.js @@ -15,25 +15,59 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ + { + input: 'index.ts', + output: [ + { file: pkg.main, 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' + } + } + }) +]; + +const es2017Builds = [ + { + 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 { - input: 'index.ts', - output: [ - { file: pkg.main, format: 'cjs', sourcemap: true }, - { file: pkg.module, format: 'es', sourcemap: true } - ], - plugins, - external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) -}; +export default [...es5Builds, ...es2017Builds]; diff --git a/packages/template/package.json b/packages/template/package.json index 1a2296d9d2e..1ba6c8e1eeb 100644 --- a/packages/template/package.json +++ b/packages/template/package.json @@ -7,6 +7,7 @@ "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -16,7 +17,7 @@ "test": "run-p test:browser test:node", "test:browser": "karma start --single-run", "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --opts ../../config/mocha.node.opts", - "prepare": "npm run build" + "prepare": "yarn build" }, "peerDependencies": { "@firebase/app": "0.x", diff --git a/packages/template/rollup.config.js b/packages/template/rollup.config.js index 5504a254b41..ee04cb1c51f 100644 --- a/packages/template/rollup.config.js +++ b/packages/template/rollup.config.js @@ -15,20 +15,24 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default [ +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ /** * Browser Builds */ @@ -38,7 +42,7 @@ export default [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, /** @@ -47,7 +51,39 @@ export default [ { input: 'index.node.ts', output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], - plugins, + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +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]; diff --git a/packages/testing/package.json b/packages/testing/package.json index 0f5ab4bc898..24e18c9ab0b 100644 --- a/packages/testing/package.json +++ b/packages/testing/package.json @@ -11,7 +11,7 @@ "build": "rollup -c", "dev": "rollup -c -w", "test": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --opts ../../config/mocha.node.opts", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/testing/rollup.config.js b/packages/testing/rollup.config.js index cfaf40d772b..477967c23cc 100644 --- a/packages/testing/rollup.config.js +++ b/packages/testing/rollup.config.js @@ -15,14 +15,15 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; import pkg from './package.json'; import replace from 'rollup-plugin-replace'; import copy from 'rollup-plugin-copy-assets'; +import typescript from 'typescript'; const plugins = [ - typescript({ - typescript: require('typescript') + typescriptPlugin({ + typescript }) ]; diff --git a/packages/util/package.json b/packages/util/package.json index 47bc25a44fa..574be948f63 100644 --- a/packages/util/package.json +++ b/packages/util/package.json @@ -6,6 +6,7 @@ "main": "dist/index.node.cjs.js", "browser": "dist/index.cjs.js", "module": "dist/index.esm.js", + "esm2017": "dist/index.esm2017.js", "files": [ "dist" ], @@ -15,7 +16,7 @@ "test": "run-p test:browser test:node", "test:browser": "karma start --single-run", "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --opts ../../config/mocha.node.opts", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "dependencies": { diff --git a/packages/util/rollup.config.js b/packages/util/rollup.config.js index 5504a254b41..bfdd6985437 100644 --- a/packages/util/rollup.config.js +++ b/packages/util/rollup.config.js @@ -15,20 +15,25 @@ * limitations under the License. */ -import typescript from 'rollup-plugin-typescript2'; +import typescriptPlugin from 'rollup-plugin-typescript2'; +import typescript from 'typescript'; import pkg from './package.json'; -const plugins = [ - typescript({ - typescript: require('typescript') - }) -]; - const deps = Object.keys( Object.assign({}, pkg.peerDependencies, pkg.dependencies) ); -export default [ +/** + * ES5 Builds + */ + +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ /** * Browser Builds */ @@ -38,7 +43,7 @@ export default [ { file: pkg.browser, format: 'cjs', sourcemap: true }, { file: pkg.module, format: 'es', sourcemap: true } ], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) }, /** @@ -47,7 +52,36 @@ export default [ { input: 'index.node.ts', output: [{ file: pkg.main, format: 'cjs', sourcemap: true }], - plugins, + plugins: es5BuildPlugins, external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) } ]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +const es2017Builds = [ + { + 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]; diff --git a/packages/webchannel-wrapper/package.json b/packages/webchannel-wrapper/package.json index abe857fd506..0aef8a8caf6 100644 --- a/packages/webchannel-wrapper/package.json +++ b/packages/webchannel-wrapper/package.json @@ -9,9 +9,9 @@ "dist" ], "scripts": { - "dev": "watch 'npm run build' src", + "dev": "watch 'yarn build' src", "build": "node tools/build.js", - "prepare": "npm run build" + "prepare": "yarn build" }, "license": "Apache-2.0", "devDependencies": { diff --git a/scripts/docgen/content-sources/js/toc.yaml b/scripts/docgen/content-sources/js/toc.yaml index 99d9446e4dc..03ba03401a2 100644 --- a/scripts/docgen/content-sources/js/toc.yaml +++ b/scripts/docgen/content-sources/js/toc.yaml @@ -146,6 +146,14 @@ toc: - title: "Messaging" path: /docs/reference/js/firebase.messaging.Messaging +- title: "firebase.performance" + path: /docs/reference/js/firebase.performance + section: + - title: "Performance" + path: /docs/reference/js/firebase.performance.Performance + - title: "Trace" + path: /docs/reference/js/firebase.performance.Trace + - title: "firebase.storage" path: /docs/reference/js/firebase.storage section: diff --git a/yarn.lock b/yarn.lock index 02e870a5ffd..498e6419af6 100644 --- a/yarn.lock +++ b/yarn.lock @@ -760,6 +760,11 @@ lodash "^4.17.11" to-fast-properties "^2.0.0" +"@firebase/logger@0.1.1": + version "0.1.1" + resolved "https://registry.npmjs.org/@firebase/logger/-/logger-0.1.1.tgz#af5df54253286993f4b367c3dabe569c848860d3" + integrity sha512-5jn3HHbEfdOwychyIEIkP1cik+MW/vvoOavTOzwDkH+fv6Bx+HBUOzh09M7sCYzXFtKzjbUax9+g39mJNBLklQ== + "@google-cloud/common@^0.17.0": version "0.17.0" resolved "http://registry.npmjs.org/@google-cloud/common/-/common-0.17.0.tgz#8ef558750db481fc10a13757a49479ab9a1c8c07" @@ -1754,11 +1759,16 @@ resolved "https://registry.npmjs.org/@types/node/-/node-9.6.4.tgz#0ef7b4cfc3499881c81e0ea1ce61a23f6f4f5b42" integrity sha512-Awg4BcUYiZtNKoveGOu654JVPt11V/KIC77iBz8NweyoOAZpz5rUJfPDwwD+ajfTs2HndbTCEB8IuLfX9m/mmw== -"@types/node@11.13.7", "@types/node@^11.13.5": +"@types/node@11.13.7": version "11.13.7" resolved "https://registry.npmjs.org/@types/node/-/node-11.13.7.tgz#85dbb71c510442d00c0631f99dae957ce44fd104" integrity sha512-suFHr6hcA9mp8vFrZTgrmqW2ZU3mbWsryQtQlY/QvwTISCw7nw/j+bCQPPohqmskhmqa5wLNuMHTTsc+xf1MQg== +"@types/node@^11.13.5": + version "11.13.8" + resolved "https://registry.npmjs.org/@types/node/-/node-11.13.8.tgz#e5d71173c95533be9842b2c798978f095f912aab" + integrity sha512-szA3x/3miL90ZJxUCzx9haNbK5/zmPieGraZEe4WI+3srN0eGLiT22NXeMHmyhNEopn+IrxqMc7wdVwvPl8meg== + "@types/normalize-package-data@^2.4.0": version "2.4.0" resolved "https://registry.npmjs.org/@types/normalize-package-data/-/normalize-package-data-2.4.0.tgz#e486d0d97396d79beedd0a6e33f4534ff6b4973e" @@ -1797,7 +1807,15 @@ "@types/glob" "*" "@types/node" "*" -"@types/sinon@7.0.11": +"@types/sinon-chai@3.2.2": + version "3.2.2" + resolved "https://registry.npmjs.org/@types/sinon-chai/-/sinon-chai-3.2.2.tgz#5cfdbda70bae30f79a9423334af9e490e4cce793" + integrity sha512-5zSs2AslzyPZdOsbm2NRtuSNAI2aTWzNKOHa/GRecKo7a5efYD7qGcPxMZXQDayVXT2Vnd5waXxBvV31eCZqiA== + dependencies: + "@types/chai" "*" + "@types/sinon" "*" + +"@types/sinon@*", "@types/sinon@7.0.11": version "7.0.11" resolved "https://registry.npmjs.org/@types/sinon/-/sinon-7.0.11.tgz#6f28f005a36e779b7db0f1359b9fb9eef72aae88" integrity sha512-6ee09Ugx6GyEr0opUIakmxIWFNmqYPjkqa3/BuxCBokA0klsOLPgMD5K4q40lH7/yZVuJVzOfQpd7pipwjngkQ== @@ -2068,7 +2086,6 @@ after@0.8.2: agent-base@4, agent-base@^4.1.0, agent-base@~4.2.0: version "4.2.1" resolved "https://registry.npmjs.org/agent-base/-/agent-base-4.2.1.tgz#d89e5999f797875674c07d87f260fc41e83e8ca9" - integrity sha512-JVwXMr9nHYTUXsBFKUqhJwvlcYU/blreOEUkhNR2eXZIvwd+c+o5V4MgDPKWnMS/56awN3TRzIP+KoPn+roQtg== dependencies: es6-promisify "^5.0.0" @@ -2572,7 +2589,7 @@ async-done@^1.2.0, async-done@^1.2.2: process-nextick-args "^1.0.7" stream-exhaust "^1.0.1" -async-each@^1.0.0, async-each@^1.0.1: +async-each@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/async-each/-/async-each-1.0.1.tgz#19d386a1d9edc6e7c1c85d388aedbcc56d33602d" integrity sha1-GdOGodntxufByF04iu28xW0zYC0= @@ -3520,23 +3537,23 @@ chokidar@^2.0.0, chokidar@^2.0.2: fsevents "^1.1.2" chokidar@^2.0.3: - version "2.1.2" - resolved "https://registry.npmjs.org/chokidar/-/chokidar-2.1.2.tgz#9c23ea40b01638439e0513864d362aeacc5ad058" - integrity sha512-IwXUx0FXc5ibYmPC2XeEj5mpXoV66sR+t3jqu2NS2GYwCktt3KF1/Qqjws/NkegajBA4RbZ5+DDwlOiJsxDHEg== + version "2.0.4" + resolved "https://registry.npmjs.org/chokidar/-/chokidar-2.0.4.tgz#356ff4e2b0e8e43e322d18a372460bbcf3accd26" dependencies: anymatch "^2.0.0" - async-each "^1.0.1" - braces "^2.3.2" + async-each "^1.0.0" + braces "^2.3.0" glob-parent "^3.1.0" - inherits "^2.0.3" + inherits "^2.0.1" is-binary-path "^1.0.0" is-glob "^4.0.0" - normalize-path "^3.0.0" + lodash.debounce "^4.0.8" + normalize-path "^2.1.1" path-is-absolute "^1.0.0" - readdirp "^2.2.1" - upath "^1.1.0" + readdirp "^2.0.0" + upath "^1.0.5" optionalDependencies: - fsevents "^1.2.7" + fsevents "^1.2.2" chownr@^1.0.1: version "1.0.1" @@ -3874,6 +3891,11 @@ commander@^2.12.1, commander@^2.8.1: resolved "https://registry.npmjs.org/commander/-/commander-2.15.1.tgz#df46e867d0fc2aec66a34662b406a9ccafff5b0f" integrity sha512-VlfT9F3V0v+jr4yxPc5gg9s62/fIVWsd2Bk2iD435um1NlGMYdVCq+MjcXnhYq2icNOizHr1kK+5TI6H0Hy0ag== +commander@^2.19.0: + version "2.20.0" + resolved "https://registry.npmjs.org/commander/-/commander-2.20.0.tgz#d58bb2b5c1ee8f87b0d340027e9e94e222c5a422" + integrity sha512-7j2y+40w61zy6YC2iRNpUe/NwhNyoXrYpHMrSunaMG64nRnaf96zO/KMQR4OyN/UnE5KLyEBnKHd4aG3rskjpQ== + commander@~2.17.1: version "2.17.1" resolved "https://registry.npmjs.org/commander/-/commander-2.17.1.tgz#bd77ab7de6de94205ceacc72f1716d29f20a77bf" @@ -3921,11 +3943,16 @@ component-bind@1.0.0: resolved "https://registry.npmjs.org/component-bind/-/component-bind-1.0.0.tgz#00c608ab7dcd93897c0009651b1d3a8e1e73bbd1" integrity sha1-AMYIq33Nk4l8AAllGx06jh5zu9E= -component-emitter@1.2.1, component-emitter@^1.2.1: +component-emitter@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.2.1.tgz#137918d6d78283f7df7a6b7c5a63e140e69425e6" integrity sha1-E3kY1teCg/ffemt8WmPhQOaUJeY= +component-emitter@^1.2.1: + version "1.3.0" + resolved "https://registry.npmjs.org/component-emitter/-/component-emitter-1.3.0.tgz#16e4070fba8ae29b679f2215853ee181ab2eabc0" + integrity sha512-Rd3se6QB+sO1TwqZjscQrurpEPIfO0/yYnSin6Q/rD3mOutHvUrCAhJub3r90uNb+SESBuE0QYoB90YdfatsRg== + component-inherit@0.0.3: version "0.0.3" resolved "https://registry.npmjs.org/component-inherit/-/component-inherit-0.0.3.tgz#645fc4adf58b72b649d5cae65135619db26ff143" @@ -4232,6 +4259,11 @@ cors@^2.8.4: object-assign "^4" vary "^1" +corser@~2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/corser/-/corser-2.0.1.tgz#8eda252ecaab5840dcd975ceb90d9370c819ff87" + integrity sha1-jtolLsqrWEDc2XXOuQ2TcMgZ/4c= + cosmiconfig@^5.1.0: version "5.1.0" resolved "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-5.1.0.tgz#6c5c35e97f37f985061cdf653f114784231185cf" @@ -4969,6 +5001,16 @@ ecdsa-sig-formatter@1.0.11: dependencies: safe-buffer "^5.0.1" +ecstatic@^3.0.0: + version "3.3.1" + resolved "https://registry.npmjs.org/ecstatic/-/ecstatic-3.3.1.tgz#b15b5b036c2233defc78d7bacbd8765226c95577" + integrity sha512-/rrctvxZ78HMI/tPIsqdvFKHHscxR3IJuKrZI2ZoUgkt2SiufyLFBmcco+aqQBIu6P1qBsUNG3drAAGLx80vTQ== + dependencies: + he "^1.1.1" + mime "^1.6.0" + minimist "^1.1.0" + url-join "^2.0.5" + editions@^1.3.3: version "1.3.4" resolved "https://registry.npmjs.org/editions/-/editions-1.3.4.tgz#3662cb592347c3168eb8e498a0ff73271d67f50b" @@ -4979,11 +5021,16 @@ ee-first@1.1.1: resolved "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz#590c61156b0ae2f4f0255732a158b266bc56b21d" integrity sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0= -electron-to-chromium@^1.3.122, electron-to-chromium@^1.3.124: +electron-to-chromium@^1.3.122: version "1.3.124" resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.124.tgz#861fc0148748a11b3e5ccebdf8b795ff513fa11f" integrity sha512-glecGr/kFdfeXUHOHAWvGcXrxNU+1wSO/t5B23tT1dtlvYB26GY8aHzZSWD7HqhqC800Lr+w/hQul6C5AF542w== +electron-to-chromium@^1.3.124: + version "1.3.125" + resolved "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.3.125.tgz#dbde0e95e64ebe322db0eca764d951f885a5aff2" + integrity sha512-XxowpqQxJ4nDwUXHtVtmEhRqBpm2OnjBomZmZtHD0d2Eo0244+Ojezhk3sD/MBSSe2nxCdGQFRXHIsf/LUTL9A== + elegant-spinner@^1.0.1: version "1.0.1" resolved "https://registry.npmjs.org/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e" @@ -5240,6 +5287,14 @@ escodegen@1.8.x: optionalDependencies: source-map "~0.2.0" +eslint-plugin-prettier@^2.2.0: + version "2.7.0" + resolved "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-2.7.0.tgz#b4312dcf2c1d965379d7f9d5b5f8aaadc6a45904" + integrity sha512-CStQYJgALoQBw3FsBzH0VOVDRnJ/ZimUlpLm226U8qgqYJfPOY/CPK6wyRInMxh73HSKg5wyRwdS4BVYYHwokA== + dependencies: + fast-diff "^1.1.1" + jest-docblock "^21.0.0" + eslint-scope@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/eslint-scope/-/eslint-scope-4.0.0.tgz#50bf3071e9338bcdc43331794a0cb533f0136172" @@ -5590,6 +5645,11 @@ fast-deep-equal@^2.0.1: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" integrity sha1-ewUhjd+WZ79/Nwv3/bLLFf3Qqkk= +fast-diff@^1.1.1: + version "1.2.0" + resolved "https://registry.npmjs.org/fast-diff/-/fast-diff-1.2.0.tgz#73ee11982d86caaf7959828d519cfe927fac5f03" + integrity sha512-xJuoT5+L99XlZ8twedaRf6Ax2TgQVxvgZOYoPKqZufmJib0tL2tegPBOZb1pVNgIhlqDlA0eO0c3wBvQcmzx4w== + fast-glob@^2.0.2: version "2.2.6" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-2.2.6.tgz#a5d5b697ec8deda468d85a74035290a025a95295" @@ -6082,13 +6142,13 @@ fsevents@^1.1.2: nan "^2.3.0" node-pre-gyp "^0.6.39" -fsevents@^1.2.7: - version "1.2.7" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-1.2.7.tgz#4851b664a3783e52003b3c66eb0eee1074933aa4" - integrity sha512-Pxm6sI2MeBD7RdD12RYsqaP0nMiwx8eZBXCa6z2L+mRHm2DYrOYwihmhjpkdjUHwQhslWQjRpEgNq4XvBmaAuw== +fsevents@^1.2.2: + version "1.2.8" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-1.2.8.tgz#57ea5320f762cd4696e5e8e87120eccc8b11cacf" + integrity sha512-tPvHgPGB7m40CZ68xqFGkKuzN+RnpGmSV+hgeKxhRpbxdqKXUFJGC3yonBOLzQBcJyGpdZFDfCsdOC2KFsXzeA== dependencies: - nan "^2.9.2" - node-pre-gyp "^0.10.0" + nan "^2.12.1" + node-pre-gyp "^0.12.0" fstream-ignore@^1.0.5: version "1.0.5" @@ -6951,7 +7011,7 @@ hawk@3.1.3, hawk@~3.1.3: hoek "2.x.x" sntp "1.x.x" -he@1.2.0: +he@1.2.0, he@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== @@ -7043,7 +7103,7 @@ http-proxy@1.16.2: eventemitter3 "1.x.x" requires-port "1.x.x" -http-proxy@^1.13.0: +http-proxy@^1.13.0, http-proxy@^1.8.1: version "1.17.0" resolved "https://registry.npmjs.org/http-proxy/-/http-proxy-1.17.0.tgz#7ad38494658f84605e2f6db4436df410f4e5be9a" integrity sha512-Taqn+3nNvYRfJ3bGvKfBSRwy1v6eePlm3oc/aWVxZp57DQr5Eq3xhKJi7Z4hZpS8PC3H4qI+Yly5EmFacGuA/g== @@ -7052,6 +7112,20 @@ http-proxy@^1.13.0: follow-redirects "^1.0.0" requires-port "^1.0.0" +http-server@0.11.1: + version "0.11.1" + resolved "https://registry.npmjs.org/http-server/-/http-server-0.11.1.tgz#2302a56a6ffef7f9abea0147d838a5e9b6b6a79b" + integrity sha512-6JeGDGoujJLmhjiRGlt8yK8Z9Kl0vnl/dQoQZlc4oeqaUoAKQg94NILLfrY3oWzSyFaQCVNTcKE5PZ3cH8VP9w== + dependencies: + colors "1.0.3" + corser "~2.0.0" + ecstatic "^3.0.0" + http-proxy "^1.8.1" + opener "~1.4.0" + optimist "0.6.x" + portfinder "^1.0.13" + union "~0.4.3" + http-signature@~1.1.0: version "1.1.1" resolved "https://registry.npmjs.org/http-signature/-/http-signature-1.1.1.tgz#df72e267066cd0ac67fb76adf8e134a8fbcf91bf" @@ -7130,6 +7204,11 @@ iconv-lite@^0.4.24, iconv-lite@^0.4.4, iconv-lite@~0.4.13: dependencies: safer-buffer ">= 2.1.2 < 3" +idb@3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/idb/-/idb-3.0.2.tgz#c8e9122d5ddd40f13b60ae665e4862f8b13fa384" + integrity sha512-+FLa/0sTXqyux0o6C+i2lOR0VoS60LU/jzUo5xjfY6+7sEEgy4Gz1O7yFBXvjd7N0NyIGWIRg8DcQSLEG+VSPw== + ieee754@^1.1.4: version "1.1.12" resolved "https://registry.npmjs.org/ieee754/-/ieee754-1.1.12.tgz#50bf24e5b9c8bb98af4964c941cdb0918da7b60b" @@ -8016,6 +8095,11 @@ jasmine@^3.3.1: glob "^7.1.3" jasmine-core "~3.4.0" +jest-docblock@^21.0.0: + version "21.2.0" + resolved "https://registry.npmjs.org/jest-docblock/-/jest-docblock-21.2.0.tgz#51529c3b30d5fd159da60c27ceedc195faf8d414" + integrity sha512-5IZ7sY9dBAYSV+YjQ0Ovb540Ku7AO9Z5o2Cg789xj167iQuZ2cG+z0f3Uct6WeYLbU6aQiM2pCs7sZ+4dotydw== + jest-worker@^24.0.0: version "24.0.0" resolved "https://registry.npmjs.org/jest-worker/-/jest-worker-24.0.0.tgz#3d3483b077bf04f412f47654a27bba7e947f8b6d" @@ -8053,6 +8137,14 @@ js-tokens@^3.0.2: resolved "https://registry.npmjs.org/js-tokens/-/js-tokens-3.0.2.tgz#9866df395102130e38f7f996bceb65443209c25b" integrity sha1-mGbfOVECEw449/mWvOtlRDIJwls= +js-yaml@3.13.0: + version "3.13.0" + resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.0.tgz#38ee7178ac0eea2c97ff6d96fff4b18c7d8cf98e" + integrity sha512-pZZoSxcCYco+DIKBTimr67J6Hy+EYGZDY/HCWC+iAEA9h1ByhMXAIVUXMcMFpOCxQ/xjXmPI2MkDL5HRm5eFrQ== + dependencies: + argparse "^1.0.7" + esprima "^4.0.0" + js-yaml@3.13.1, js-yaml@^3.13.0: version "3.13.1" resolved "https://registry.npmjs.org/js-yaml/-/js-yaml-3.13.1.tgz#aff151b30bfdfa8e49e05da22e7415e9dfa37847" @@ -8621,6 +8713,11 @@ liftoff@^3.1.0: rechoir "^0.6.2" resolve "^1.1.7" +lines-and-columns@^1.1.6: + version "1.1.6" + resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.1.6.tgz#1c00c743b433cd0a4e80758f7b64a57440d9ff00" + integrity sha1-HADHQ7QzzQpOgHWPe2SldEDZ/wA= + listr-silent-renderer@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e" @@ -8753,6 +8850,11 @@ lodash.clonedeep@^4.5.0: resolved "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz#e23f3f9c4f8fbdde872529c1071857a086e5ccef" integrity sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8= +lodash.debounce@^4.0.8: + version "4.0.8" + resolved "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz#82d79bff30a67c4005ffd5e2515300ad9ca4d7af" + integrity sha1-gteb/zCmfEAF/9XiUVMArZyk168= + lodash.flattendeep@^4.4.0: version "4.4.0" resolved "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz#fb030917f86a3134e5bc9bec0d69e0013ddfedb2" @@ -9316,7 +9418,7 @@ mime@1.4.1: resolved "https://registry.npmjs.org/mime/-/mime-1.4.1.tgz#121f9ebc49e3766f311a76e1fa1c8003c4b03aa6" integrity sha512-KI1+qOZu5DcW6wayYHSzR/tXKCDC5Om4s1z2QJjDULzLcmf3DvzS7oluY4HCTrc+9FiKmWUgeNLg7W3uIQvxtQ== -mime@^1.4.1: +mime@^1.4.1, mime@^1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz#32cd9e5c64553bd58d19a568af452acff04981b1" integrity sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg== @@ -9422,6 +9524,35 @@ mkdirp@0.5.1, mkdirp@0.5.x, mkdirp@0.x.x, "mkdirp@>=0.5 0", mkdirp@^0.5.0, mkdir dependencies: minimist "0.0.8" +mocha@6.1.3: + version "6.1.3" + resolved "https://registry.npmjs.org/mocha/-/mocha-6.1.3.tgz#79d1b370a92dfdb409e6f77bc395ca5afe4cdc93" + integrity sha512-QdE/w//EPHrqgT5PNRUjRVHy6IJAzAf1R8n2O8W8K2RZ+NbPfOD5cBDp+PGa2Gptep37C/TdBiaNwakppEzEbg== + dependencies: + ansi-colors "3.2.3" + browser-stdout "1.3.1" + debug "3.2.6" + diff "3.5.0" + escape-string-regexp "1.0.5" + find-up "3.0.0" + glob "7.1.3" + growl "1.10.5" + he "1.2.0" + js-yaml "3.13.0" + log-symbols "2.2.0" + minimatch "3.0.4" + mkdirp "0.5.1" + ms "2.1.1" + node-environment-flags "1.0.5" + object.assign "4.1.0" + strip-json-comments "2.0.1" + supports-color "6.0.0" + which "1.3.1" + wide-align "1.1.3" + yargs "13.2.2" + yargs-parser "13.0.0" + yargs-unparser "1.5.0" + mocha@6.1.4: version "6.1.4" resolved "https://registry.npmjs.org/mocha/-/mocha-6.1.4.tgz#e35fada242d5434a7e163d555c705f6875951640" @@ -9559,11 +9690,16 @@ mz@2.7.0: object-assign "^4.0.1" thenify-all "^1.0.0" -nan@^2.0.0, nan@^2.3.0, nan@^2.9.2: +nan@^2.0.0, nan@^2.3.0: version "2.12.1" resolved "https://registry.npmjs.org/nan/-/nan-2.12.1.tgz#7b1aa193e9aa86057e3c7bbd0ac448e770925552" integrity sha512-JY7V6lRkStKcKTvHO5NVSQRv+RV+FIL5pvDoLiAtSL9pKlC5x9PKQcZDsq7m4FO4d57mkhC6Z+QhAh3Jdk5JFw== +nan@^2.12.1: + version "2.13.2" + resolved "https://registry.npmjs.org/nan/-/nan-2.13.2.tgz#f51dc7ae66ba7d5d55e1e6d4d8092e802c9aefe7" + integrity sha512-TghvYc72wlMGMVMluVo9WRJc0mB8KxxF/gZ4YYFy7V2ZQX9l7rgbPg7vjS9mt6U5HXODVFVI2bOduCzwOMv/lw== + nan@~2.10.0: version "2.10.0" resolved "https://registry.npmjs.org/nan/-/nan-2.10.0.tgz#96d0cd610ebd58d4b4de9cc0c6828cda99c7548f" @@ -9735,22 +9871,6 @@ node-localstorage@^1.3.0: dependencies: write-file-atomic "^1.1.4" -node-pre-gyp@^0.10.0: - version "0.10.3" - resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.10.3.tgz#3070040716afdc778747b61b6887bf78880b80fc" - integrity sha512-d1xFs+C/IPS8Id0qPTZ4bUT8wWryfR/OzzAFxweG+uLN85oPzyo2Iw6bVlLQ/JOdgNonXLCoRyqDzDWq4iw72A== - dependencies: - detect-libc "^1.0.2" - mkdirp "^0.5.1" - needle "^2.2.1" - nopt "^4.0.1" - npm-packlist "^1.1.6" - npmlog "^4.0.2" - rc "^1.2.7" - rimraf "^2.6.1" - semver "^5.3.0" - tar "^4" - node-pre-gyp@^0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/node-pre-gyp/-/node-pre-gyp-0.11.0.tgz#db1f33215272f692cd38f03238e3e9b47c5dd054" @@ -9868,11 +9988,6 @@ normalize-path@^2.0.0, normalize-path@^2.0.1, normalize-path@^2.1.1: dependencies: remove-trailing-separator "^1.0.1" -normalize-path@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz#0dcd69ff23a1c9b11fd0978316644a0388216a65" - integrity sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA== - normalize-url@2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/normalize-url/-/normalize-url-2.0.1.tgz#835a9da1551fa26f70e92329069a23aa6574d7e6" @@ -10159,6 +10274,11 @@ onetime@^2.0.0: dependencies: mimic-fn "^1.0.0" +opener@~1.4.0: + version "1.4.3" + resolved "https://registry.npmjs.org/opener/-/opener-1.4.3.tgz#5c6da2c5d7e5831e8ffa3964950f8d6674ac90b8" + integrity sha1-XG2ixdflgx6P+jlklQ+NZnSskLg= + opn@^5.5.0: version "5.5.0" resolved "https://registry.npmjs.org/opn/-/opn-5.5.0.tgz#fc7164fab56d235904c51c3b27da6758ca3b9bfc" @@ -10166,7 +10286,7 @@ opn@^5.5.0: dependencies: is-wsl "^1.1.0" -optimist@^0.6.1, optimist@~0.6.0: +optimist@0.6.x, optimist@^0.6.1, optimist@~0.6.0: version "0.6.1" resolved "https://registry.npmjs.org/optimist/-/optimist-0.6.1.tgz#da3ea74686fa21a19a111c326e90eb15a0196686" integrity sha1-2j6nRob6IaGaERwybpDrFaAZZoY= @@ -11042,6 +11162,11 @@ qs@6.5.2, qs@~6.5.2: resolved "https://registry.npmjs.org/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" integrity sha512-N5ZAX4/LxJmF+7wN74pUD6qAh9/wnvdQcjq9TZjevvXzSUo7bfmw91saqMjzGS2xq91/odN2dW/WOl7qQHNDGA== +qs@~2.3.3: + version "2.3.3" + resolved "https://registry.npmjs.org/qs/-/qs-2.3.3.tgz#e9e85adbe75da0bbe4c8e0476a086290f863b404" + integrity sha1-6eha2+ddoLvkyOBHaghikPhjtAQ= + qs@~6.4.0: version "6.4.0" resolved "https://registry.npmjs.org/qs/-/qs-6.4.0.tgz#13e26d28ad6b0ffaa91312cd3bf708ed351e7233" @@ -11269,7 +11394,7 @@ readdir-scoped-modules@^1.0.0: graceful-fs "^4.1.2" once "^1.3.0" -readdirp@^2.0.0, readdirp@^2.2.1: +readdirp@^2.0.0: version "2.2.1" resolved "https://registry.npmjs.org/readdirp/-/readdirp-2.2.1.tgz#0e87622a3325aa33e892285caf8b4e846529a525" integrity sha512-1JU/8q+VgFZyxwrJ+SVIOsh+KywWGpds3NTqikiKpDMZWScmAYyKIgqkO+ARvNWJfXeXR1zxz7aHF4u4CyH6vQ== @@ -11733,6 +11858,16 @@ rollup-plugin-sourcemaps@0.4.2: rollup-pluginutils "^2.0.1" source-map-resolve "^0.5.0" +rollup-plugin-terser@4.0.4: + version "4.0.4" + resolved "https://registry.npmjs.org/rollup-plugin-terser/-/rollup-plugin-terser-4.0.4.tgz#6f661ef284fa7c27963d242601691dc3d23f994e" + integrity sha512-wPANT5XKVJJ8RDUN0+wIr7UPd0lIXBo4UdJ59VmlPCtlFsE20AM+14pe+tk7YunCsWEiuzkDBY3QIkSCjtrPXg== + dependencies: + "@babel/code-frame" "^7.0.0" + jest-worker "^24.0.0" + serialize-javascript "^1.6.1" + terser "^3.14.1" + rollup-plugin-typescript2@0.21.0: version "0.21.0" resolved "https://registry.npmjs.org/rollup-plugin-typescript2/-/rollup-plugin-typescript2-0.21.0.tgz#cc61ed756ac6e68cb3c03f7ee78001346243ed54" @@ -12162,6 +12297,11 @@ simple-git@1.110.0: dependencies: debug "^4.0.1" +sinon-chai@3.3.0: + version "3.3.0" + resolved "https://registry.npmjs.org/sinon-chai/-/sinon-chai-3.3.0.tgz#8084ff99451064910fbe2c2cb8ab540c00b740ea" + integrity sha512-r2JhDY7gbbmh5z3Q62pNbrjxZdOAjpsqW/8yxAZRSqLZqowmfGZPGUZPFf3UX36NLis0cv8VEM5IJh9HgkSOAA== + sinon@7.3.2: version "7.3.2" resolved "https://registry.npmjs.org/sinon/-/sinon-7.3.2.tgz#82dba3a6d85f6d2181e1eca2c10d8657c2161f28" @@ -12195,7 +12335,7 @@ slide@^1.1.5, slide@^1.1.6: resolved "https://registry.npmjs.org/slide/-/slide-1.1.6.tgz#56eb027d65b4d2dce6cb2e2d32c4d4afc9e1d707" integrity sha1-VusCfWW00tzmyy4tMsTUr8nh1wc= -smart-buffer@4.0.2: +smart-buffer@^4.0.1: version "4.0.2" resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.0.2.tgz#5207858c3815cc69110703c6b94e46c15634395d" integrity sha512-JDhEpTKzXusOqXZ0BUIdH+CjFdO/CR3tLlf5CN34IypI+xMmXW1uB16OOY8z3cICbJlDAVJzNbwBhNO0wt9OAw== @@ -12297,12 +12437,11 @@ socks-proxy-agent@^4.0.0: socks "~2.2.0" socks@~2.2.0: - version "2.2.3" - resolved "https://registry.npmjs.org/socks/-/socks-2.2.3.tgz#7399ce11e19b2a997153c983a9ccb6306721f2dc" - integrity sha512-+2r83WaRT3PXYoO/1z+RDEBE7Z2f9YcdQnJ0K/ncXXbV5gJ6wYfNAebYFYiiUjM6E4JyXnPY8cimwyvFYHVUUA== + version "2.2.1" + resolved "https://registry.npmjs.org/socks/-/socks-2.2.1.tgz#68ad678b3642fbc5d99c64c165bc561eab0215f9" dependencies: ip "^1.1.5" - smart-buffer "4.0.2" + smart-buffer "^4.0.1" sort-keys@^2.0.0: version "2.0.0" @@ -12350,6 +12489,14 @@ source-map-support@~0.4.0: dependencies: source-map "^0.5.6" +source-map-support@~0.5.10: + version "0.5.12" + resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.12.tgz#b4f3b10d51857a5af0138d3ce8003b201613d599" + integrity sha512-4h2Pbvyy15EE02G+JOZpUCmqWJuqrs+sEkzewTm++BPi7Hvn/HwcqLAcNxYAyI0x13CpPPn+kMjl+hplXMHITQ== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map-url@^0.4.0: version "0.4.0" resolved "https://registry.npmjs.org/source-map-url/-/source-map-url-0.4.0.tgz#3e935d7ddd73631b97659956d55128e87b5084a3" @@ -13002,6 +13149,15 @@ terser-webpack-plugin@^1.1.0: webpack-sources "^1.1.0" worker-farm "^1.5.2" +terser@^3.14.1: + version "3.17.0" + resolved "https://registry.npmjs.org/terser/-/terser-3.17.0.tgz#f88ffbeda0deb5637f9d24b0da66f4e15ab10cb2" + integrity sha512-/FQzzPJmCpjAH9Xvk2paiWrFq+5M6aVOf+2KRbwhByISDX/EujxsK+BAvrhb6H+2rtrLCHK9N01wO014vrIwVQ== + dependencies: + commander "^2.19.0" + source-map "~0.6.1" + source-map-support "~0.5.10" + terser@^3.16.1: version "3.16.1" resolved "https://registry.npmjs.org/terser/-/terser-3.16.1.tgz#5b0dd4fa1ffd0b0b43c2493b2c364fd179160493" @@ -13286,11 +13442,32 @@ ts-node@8.1.0: source-map-support "^0.5.6" yn "^3.0.0" -tslib@1.9.3, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: +tslib@1.9.3, tslib@^1.7.1, tslib@^1.8.0, tslib@^1.8.1, tslib@^1.9.0: version "1.9.3" resolved "https://registry.npmjs.org/tslib/-/tslib-1.9.3.tgz#d7e4dd79245d85428c4d7e4822a79917954ca286" integrity sha512-4krF8scpejhaOgqzBEcGM7yDIEfi0/8+8zDRZhNZZ2kjmHJ4hv3zCbQWxoJGz1iw5U0Jl0nma13xzHXcncMavQ== +tslint-config-prettier@1.18.0: + version "1.18.0" + resolved "https://registry.npmjs.org/tslint-config-prettier/-/tslint-config-prettier-1.18.0.tgz#75f140bde947d35d8f0d238e0ebf809d64592c37" + integrity sha512-xPw9PgNPLG3iKRxmK7DWr+Ea/SzrvfHtjFt5LBl61gk2UBG/DB9kCXRjv+xyIU1rUtnayLeMUVJBcMX8Z17nDg== + +tslint-no-unused-expression-chai@0.1.4: + version "0.1.4" + resolved "https://registry.npmjs.org/tslint-no-unused-expression-chai/-/tslint-no-unused-expression-chai-0.1.4.tgz#f4a2c9dd3306088f44eb7574cf470082b09ade49" + integrity sha512-frEWKNTcq7VsaWKgUxMDOB2N/cmQadVkUtUGIut+2K4nv/uFXPfgJyPjuNC/cHyfUVqIkHMAvHOCL+d/McU3nQ== + dependencies: + tsutils "^3.0.0" + +tslint-plugin-prettier@2.0.1: + version "2.0.1" + resolved "https://registry.npmjs.org/tslint-plugin-prettier/-/tslint-plugin-prettier-2.0.1.tgz#95b6a3b766622ffc44375825d7760225c50c3680" + integrity sha512-4FX9JIx/1rKHIPJNfMb+ooX1gPk5Vg3vNi7+dyFYpLO+O57F4g+b/fo1+W/G0SUOkBLHB/YKScxjX/P+7ZT/Tw== + dependencies: + eslint-plugin-prettier "^2.2.0" + lines-and-columns "^1.1.6" + tslib "^1.7.1" + tslint@5.16.0: version "5.16.0" resolved "https://registry.npmjs.org/tslint/-/tslint-5.16.0.tgz#ae61f9c5a98d295b9a4f4553b1b1e831c1984d67" @@ -13317,6 +13494,13 @@ tsutils@^2.29.0: dependencies: tslib "^1.8.1" +tsutils@^3.0.0: + version "3.9.1" + resolved "https://registry.npmjs.org/tsutils/-/tsutils-3.9.1.tgz#2a40dc742943c71eca6d5c1994fcf999956be387" + integrity sha512-hrxVtLtPqQr//p8/msPT1X1UYXUjizqSit5d9AQ5k38TcV38NyecL5xODNxa73cLe/5sdiJ+w1FqzDhRBA/anA== + dependencies: + tslib "^1.8.1" + tty-browserify@0.0.0: version "0.0.0" resolved "https://registry.npmjs.org/tty-browserify/-/tty-browserify-0.0.0.tgz#a157ba402da24e9bf957f9aa69d524eed42901a6" @@ -13542,6 +13726,13 @@ union-value@^1.0.0: is-extendable "^0.1.1" set-value "^0.4.3" +union@~0.4.3: + version "0.4.6" + resolved "https://registry.npmjs.org/union/-/union-0.4.6.tgz#198fbdaeba254e788b0efcb630bc11f24a2959e0" + integrity sha1-GY+9rrolTniLDvy2MLwR8kopWeA= + dependencies: + qs "~2.3.3" + unique-filename@^1.1.1: version "1.1.1" resolved "https://registry.npmjs.org/unique-filename/-/unique-filename-1.1.1.tgz#1d69769369ada0583103a1e6ae87681b56573230" @@ -13615,11 +13806,16 @@ unzip-response@^2.0.1: resolved "https://registry.npmjs.org/unzip-response/-/unzip-response-2.0.1.tgz#d2f0f737d16b0615e72a6935ed04214572d56f97" integrity sha1-0vD3N9FrBhXnKmk17QQhRXLVb5c= -upath@^1.0.0, upath@^1.1.0: +upath@^1.0.0: version "1.1.0" resolved "https://registry.npmjs.org/upath/-/upath-1.1.0.tgz#35256597e46a581db4793d0ce47fa9aebfc9fabd" integrity sha512-bzpH/oBhoS/QI/YtbkqCg6VEiPYjSZtrHQM6/QnJS6OL9pKUFLqb3aFh4Scvwm45+7iAgiMkLhSbaZxUqmrprw== +upath@^1.0.5: + version "1.1.2" + resolved "https://registry.npmjs.org/upath/-/upath-1.1.2.tgz#3db658600edaeeccbe6db5e684d67ee8c2acd068" + integrity sha512-kXpym8nmDmlCBr7nKdIx8P2jNBa+pBpIUFRnKJ4dr8htyYGJFokkr2ZvERRtUN+9SY+JqXouNgUPtv6JQva/2Q== + update-notifier@^2.5.0: version "2.5.0" resolved "https://registry.npmjs.org/update-notifier/-/update-notifier-2.5.0.tgz#d0744593e13f161e406acb1d9408b72cad08aff6" @@ -13660,7 +13856,7 @@ url-join@0.0.1: resolved "https://registry.npmjs.org/url-join/-/url-join-0.0.1.tgz#1db48ad422d3402469a87f7d97bdebfe4fb1e3c8" integrity sha1-HbSK1CLTQCRpqH99l73r/k+x48g= -url-join@^2.0.2: +url-join@^2.0.2, url-join@^2.0.5: version "2.0.5" resolved "https://registry.npmjs.org/url-join/-/url-join-2.0.5.tgz#5af22f18c052a000a48d7b82c5e9c2e2feeda728" integrity sha1-WvIvGMBSoACkjXuCxenC4v7tpyg=