diff --git a/packages/analytics-interop-types/README.md b/packages/analytics-interop-types/README.md new file mode 100644 index 00000000000..dc295ac7122 --- /dev/null +++ b/packages/analytics-interop-types/README.md @@ -0,0 +1,3 @@ +# @firebase/analytics-interop-types + +**This package is not intended for direct usage, and should only be used via the officially supported [firebase](https://www.npmjs.com/package/firebase) package.** diff --git a/packages/analytics-interop-types/index.d.ts b/packages/analytics-interop-types/index.d.ts new file mode 100644 index 00000000000..d246734599c --- /dev/null +++ b/packages/analytics-interop-types/index.d.ts @@ -0,0 +1,46 @@ +/** + * @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 FirebaseAnalyticsInternal { + /** + * Sends analytics event with given `eventParams`. This method + * automatically associates this logged event with this Firebase web + * app instance on this device. + * List of official event parameters can be found in + * {@link https://developers.google.com/gtagjs/reference/event + * the gtag.js reference documentation}. + */ + logEvent( + eventName: string, + eventParams?: { [key: string]: unknown }, + options?: AnalyticsCallOptions + ): void; +} + +export interface AnalyticsCallOptions { + /** + * If true, this config or event call applies globally to all + * analytics properties on the page. + */ + global: boolean; +} + +declare module '@firebase/component' { + interface NameServiceMapping { + 'analytics-internal': FirebaseAnalyticsInternal; + } +} diff --git a/packages/analytics-interop-types/package.json b/packages/analytics-interop-types/package.json new file mode 100644 index 00000000000..9fc46357fef --- /dev/null +++ b/packages/analytics-interop-types/package.json @@ -0,0 +1,24 @@ +{ + "name": "@firebase/analytics-interop-types", + "version": "0.1.0", + "description": "@firebase/analytics Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc" + }, + "files": [ + "index.d.ts" + ], + "repository": { + "directory": "packages/analytics-interop-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.6.4" + } +} diff --git a/packages/analytics-interop-types/tsconfig.json b/packages/analytics-interop-types/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages/analytics-interop-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/analytics-types/index.d.ts b/packages/analytics-types/index.d.ts index ef4042fe393..328458b6925 100644 --- a/packages/analytics-types/index.d.ts +++ b/packages/analytics-types/index.d.ts @@ -201,3 +201,9 @@ export interface Promotion { id?: string; name?: string; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'analytics': FirebaseAnalytics; + } +} diff --git a/packages/analytics/index.test.ts b/packages/analytics/index.test.ts index 58964597a28..c54c46a6c7f 100644 --- a/packages/analytics/index.test.ts +++ b/packages/analytics/index.test.ts @@ -41,12 +41,12 @@ const customDataLayerName = 'customDataLayer'; describe('FirebaseAnalytics instance tests', () => { it('Throws if no analyticsId in config', () => { const app = getFakeApp(); - expect(() => analyticsFactory(app, () => {})).to.throw('field is empty'); + expect(() => analyticsFactory(app)).to.throw('field is empty'); }); it('Throws if creating an instance with already-used analytics ID', () => { const app = getFakeApp(analyticsId); resetGlobalVars(false, { [analyticsId]: Promise.resolve() }); - expect(() => analyticsFactory(app, () => {})).to.throw('already exists'); + expect(() => analyticsFactory(app)).to.throw('already exists'); }); describe('Standard app, page already has user gtag script', () => { let app: FirebaseApp = {} as FirebaseApp; @@ -55,7 +55,7 @@ describe('FirebaseAnalytics instance tests', () => { app = getFakeApp(analyticsId); window['gtag'] = gtagStub; window['dataLayer'] = []; - analyticsInstance = analyticsFactory(app, () => {}); + analyticsInstance = analyticsFactory(app); }); after(() => { delete window['gtag']; @@ -121,7 +121,7 @@ describe('FirebaseAnalytics instance tests', () => { dataLayerName: customDataLayerName, gtagName: customGtagName }); - analyticsInstance = analyticsFactory(app, () => {}); + analyticsInstance = analyticsFactory(app); }); after(() => { delete window[customGtagName]; @@ -164,7 +164,7 @@ describe('FirebaseAnalytics instance tests', () => { before(() => { resetGlobalVars(); const app = getFakeApp(analyticsId); - analyticsInstance = analyticsFactory(app, () => {}); + analyticsInstance = analyticsFactory(app); }); after(() => { delete window['gtag']; diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts index 9d4e4b248c7..50e88069f2d 100644 --- a/packages/analytics/index.ts +++ b/packages/analytics/index.ts @@ -16,12 +16,12 @@ */ import firebase from '@firebase/app'; import { FirebaseAnalytics } from '@firebase/analytics-types'; -import { - FirebaseServiceFactory, - _FirebaseNamespace -} from '@firebase/app-types/private'; +import { FirebaseAnalyticsInternal } from '@firebase/analytics-interop-types'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { factory, settings, resetGlobalVars } from './src/factory'; import { EventName } from './src/constants'; +import { Component, ComponentType } from '@firebase/component'; +import { ERROR_FACTORY, AnalyticsError } from './src/errors'; declare global { interface Window { @@ -35,17 +35,41 @@ declare global { const ANALYTICS_TYPE = 'analytics'; export function registerAnalytics(instance: _FirebaseNamespace): void { - instance.INTERNAL.registerService( - ANALYTICS_TYPE, - factory as FirebaseServiceFactory, - { + instance.INTERNAL.registerComponent( + new Component( + ANALYTICS_TYPE, + container => { + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + return factory(app); + }, + ComponentType.PUBLIC + ).setServiceProps({ settings, EventName - }, - // We don't need to wait on any AppHooks. - undefined, - // Allow multiple analytics instances per app. - false + }) + ); + + instance.INTERNAL.registerComponent( + new Component( + 'analytics-internal', + container => { + try { + const analytics = container + .getProvider(ANALYTICS_TYPE) + .getImmediate(); + return { + logEvent: analytics.logEvent + }; + } catch (e) { + throw ERROR_FACTORY.create( + AnalyticsError.INTEROP_COMPONENT_REG_FAILED, + { reason: e } + ); + } + }, + ComponentType.PRIVATE + ) ); } diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 05e2eee576f..3dae058f30a 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -26,6 +26,7 @@ "@firebase/analytics-types": "0.2.2", "@firebase/installations": "0.3.2", "@firebase/util": "0.2.31", + "@firebase/component": "0.1.0", "tslib": "1.10.0" }, "license": "Apache-2.0", diff --git a/packages/analytics/src/errors.ts b/packages/analytics/src/errors.ts index 8487e0dc992..dc03b32b8c4 100644 --- a/packages/analytics/src/errors.ts +++ b/packages/analytics/src/errors.ts @@ -21,7 +21,8 @@ import { ANALYTICS_ID_FIELD } from './constants'; export const enum AnalyticsError { NO_GA_ID = 'no-ga-id', ALREADY_EXISTS = 'already-exists', - ALREADY_INITIALIZED = 'already-initialized' + ALREADY_INITIALIZED = 'already-initialized', + INTEROP_COMPONENT_REG_FAILED = 'interop-component-reg-failed' } const ERRORS: ErrorMap = { @@ -36,11 +37,14 @@ const ERRORS: ErrorMap = { [AnalyticsError.ALREADY_INITIALIZED]: 'Firebase Analytics has already been initialized.' + 'settings() must be called before initializing any Analytics instance' + - 'or it will have no effect.' + 'or it will have no effect.', + [AnalyticsError.INTEROP_COMPONENT_REG_FAILED]: + 'Firebase Analytics Interop Component failed to instantiate' }; interface ErrorParams { [AnalyticsError.ALREADY_EXISTS]: { id: string }; + [AnalyticsError.INTEROP_COMPONENT_REG_FAILED]: { reason: Error }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/analytics/src/factory.ts b/packages/analytics/src/factory.ts index d8e6e2717c9..e094f43c674 100644 --- a/packages/analytics/src/factory.ts +++ b/packages/analytics/src/factory.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { FirebaseAnalytics, Gtag, @@ -38,6 +37,7 @@ import { } from './helpers'; import { ANALYTICS_ID_FIELD } from './constants'; import { AnalyticsError, ERROR_FACTORY } from './errors'; +import { FirebaseApp } from '@firebase/app-types'; /** * Maps gaId to FID fetch promises. @@ -102,11 +102,7 @@ export function settings(options: SettingsOptions): void { } } -export function factory( - app: FirebaseApp, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - extendApp: (props: { [prop: string]: any }) => void -): FirebaseAnalytics { +export function factory(app: FirebaseApp): FirebaseAnalytics { const analyticsId = app.options[ANALYTICS_ID_FIELD]; if (!analyticsId) { throw ERROR_FACTORY.create(AnalyticsError.NO_GA_ID); @@ -162,13 +158,5 @@ export function factory( setAnalyticsCollectionEnabled(analyticsId, enabled) }; - extendApp({ - INTERNAL: { - analytics: { - logEvent: analyticsInstance.logEvent - } - } - }); - return analyticsInstance; } diff --git a/packages/app-types/index.d.ts b/packages/app-types/index.d.ts index 10b89b83bb9..9b2c33e96df 100644 --- a/packages/app-types/index.d.ts +++ b/packages/app-types/index.d.ts @@ -101,3 +101,9 @@ export interface FirebaseNamespace { // The current SDK version. SDK_VERSION: string; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'app': FirebaseApp; + } +} diff --git a/packages/app-types/private.d.ts b/packages/app-types/private.d.ts index 045a8734456..40b232910fa 100644 --- a/packages/app-types/private.d.ts +++ b/packages/app-types/private.d.ts @@ -23,6 +23,8 @@ import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; import { Observer, Subscribe } from '@firebase/util'; import { FirebaseError, ErrorFactory } from '@firebase/util'; +import { Deferred } from '../firestore/test/util/promise'; +import { Component } from '@firebase/component'; export interface FirebaseServiceInternals { /** @@ -80,8 +82,8 @@ export interface FirebaseAppInternals { } export interface _FirebaseApp extends FirebaseApp { - INTERNAL: FirebaseAppInternals; - _removeServiceInstance: (name: string, instanceIdentifier?: string) => void; + _addComponent(component: Component): void; + _removeServiceInstance(name: string, instanceIdentifier?: string): void; } export interface _FirebaseNamespace extends FirebaseNamespace { INTERNAL: { @@ -99,13 +101,9 @@ export interface _FirebaseNamespace extends FirebaseNamespace { * @param allowMultipleInstances Whether the registered service supports * multiple instances per app. If not specified, the default is false. */ - registerService( - name: string, - createService: FirebaseServiceFactory, - serviceProperties?: { [prop: string]: any }, - appHook?: AppHook, - allowMultipleInstances?: boolean - ): FirebaseServiceNamespace; + registerComponent( + component: Component + ): FirebaseServiceNamespace | null; /** * Just used for testing to start from a fresh namespace. @@ -139,9 +137,9 @@ export interface _FirebaseNamespace extends FirebaseNamespace { removeApp(name: string): void; /** - * Service factories for each registered service. + * registered components. */ - factories: { [name: string]: FirebaseServiceFactory }; + components: Map; /* * Convert service name to factory name to use. diff --git a/packages/app/index.node.ts b/packages/app/index.node.ts index 12a396fa1f1..f7e81b690e5 100644 --- a/packages/app/index.node.ts +++ b/packages/app/index.node.ts @@ -17,16 +17,14 @@ import { FirebaseNamespace } from '@firebase/app-types'; import { _FirebaseNamespace } from '@firebase/app-types/private'; -import { createFirebaseNamespace } from './src/firebaseNamespace'; +import { firebase as _firebase } from './src/firebaseNamespace'; // Node specific packages. // @ts-ignore import Storage from 'dom-storage'; // @ts-ignore import { XMLHttpRequest } from 'xmlhttprequest'; -const _firebase = createFirebaseNamespace() as _FirebaseNamespace; - -_firebase.INTERNAL.extendNamespace({ +(_firebase as _FirebaseNamespace).INTERNAL.extendNamespace({ INTERNAL: { node: { localStorage: new Storage(null, { strict: true }), diff --git a/packages/app/index.rn.ts b/packages/app/index.rn.ts index 2669b4a7ac8..e0177ac2662 100644 --- a/packages/app/index.rn.ts +++ b/packages/app/index.rn.ts @@ -17,8 +17,7 @@ import { FirebaseNamespace } from '@firebase/app-types'; import { _FirebaseNamespace } from '@firebase/app-types/private'; -import { createFirebaseNamespace } from './src/firebaseNamespace'; - +import { firebase as _firebase } from './src/firebaseNamespace'; /** * To avoid having to include the @types/react-native package, which breaks * some of our tests because of duplicate symbols, we are using require syntax @@ -27,9 +26,7 @@ import { createFirebaseNamespace } from './src/firebaseNamespace'; // eslint-disable-next-line @typescript-eslint/no-require-imports const { AsyncStorage } = require('react-native'); -const _firebase = createFirebaseNamespace() as _FirebaseNamespace; - -_firebase.INTERNAL.extendNamespace({ +(_firebase as _FirebaseNamespace).INTERNAL.extendNamespace({ INTERNAL: { reactNative: { AsyncStorage diff --git a/packages/app/index.ts b/packages/app/index.ts index 350112c536c..2170cce61a0 100644 --- a/packages/app/index.ts +++ b/packages/app/index.ts @@ -16,7 +16,7 @@ */ import { FirebaseNamespace } from '@firebase/app-types'; -import { createFirebaseNamespace } from './src/firebaseNamespace'; +import { firebase as firebaseNamespace } from './src/firebaseNamespace'; import { isNode, isBrowser } from '@firebase/util'; import { logger } from './src/logger'; @@ -38,7 +38,6 @@ if (isBrowser() && (self as any).firebase !== undefined) { } } -const firebaseNamespace = createFirebaseNamespace(); const initializeApp = firebaseNamespace.initializeApp; // TODO: This disable can be removed and the 'ignoreRestArgs' option added to diff --git a/packages/app/karma.conf.js b/packages/app/karma.conf.js index c422d2666ef..16c3a0d9bc3 100644 --- a/packages/app/karma.conf.js +++ b/packages/app/karma.conf.js @@ -19,7 +19,7 @@ const karma = require('karma'); const path = require('path'); const karmaBase = require('../../config/karma.base'); -const files = [`test/**/*`]; +const files = ['test/**/*', 'src/**/*.test.ts']; module.exports = function(config) { const karmaConfig = Object.assign({}, karmaBase, { @@ -27,6 +27,7 @@ module.exports = function(config) { files: files, // frameworks to use // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, frameworks: ['mocha'] }); diff --git a/packages/app/package.json b/packages/app/package.json index d27f58e01d7..b8c5d32cf97 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -21,7 +21,7 @@ "test": "run-p lint test:browser test:node", "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", + "test:node": "TS_NODE_FILES=true TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha test/**/*.test.* --opts ../../config/mocha.node.opts", "prepare": "yarn build" }, "license": "Apache-2.0", @@ -29,6 +29,7 @@ "@firebase/app-types": "0.4.6", "@firebase/util": "0.2.31", "@firebase/logger": "0.1.28", + "@firebase/component": "0.1.0", "tslib": "1.10.0", "dom-storage": "2.1.0", "xmlhttprequest": "1.8.0" @@ -56,6 +57,7 @@ "rollup-plugin-typescript2": "0.24.3", "rollup-plugin-json": "4.0.0", "sinon": "7.5.0", + "sinon-chai": "3.3.0", "source-map-loader": "0.2.4", "ts-loader": "6.2.1", "ts-node": "8.4.1", diff --git a/packages/app/src/firebaseApp.ts b/packages/app/src/firebaseApp.ts index 3613315dcde..d406c20cdc0 100644 --- a/packages/app/src/firebaseApp.ts +++ b/packages/app/src/firebaseApp.ts @@ -23,18 +23,18 @@ import { import { _FirebaseApp, _FirebaseNamespace, - FirebaseService, - FirebaseAppInternals + FirebaseService } from '@firebase/app-types/private'; -import { deepCopy, deepExtend } from '@firebase/util'; +import { deepCopy } from '@firebase/util'; +import { + ComponentContainer, + Component, + ComponentType, + Name +} from '@firebase/component'; import { AppError, ERROR_FACTORY } from './errors'; import { DEFAULT_ENTRY_NAME } from './constants'; - -interface ServicesCache { - [name: string]: { - [serviceName: string]: FirebaseService; - }; -} +import { logger } from './logger'; /** * Global context object for a collection of services using @@ -44,15 +44,8 @@ export class FirebaseAppImpl implements FirebaseApp { private readonly options_: FirebaseOptions; private readonly name_: string; private isDeleted_ = false; - private services_: ServicesCache = {}; private automaticDataCollectionEnabled_: boolean; - // An array to capture listeners before the true auth functions exist - private tokenListeners_: Array<(token: string | null) => void> = []; - // An array to capture requests to send events before analytics component loads. Use type any to make using function.apply easier - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private analyticsEventRequests_: any[] = []; - - INTERNAL: FirebaseAppInternals; + private container: ComponentContainer; constructor( options: FirebaseOptions, @@ -63,26 +56,14 @@ export class FirebaseAppImpl implements FirebaseApp { this.automaticDataCollectionEnabled_ = config.automaticDataCollectionEnabled || false; this.options_ = deepCopy(options); - const self = this; - this.INTERNAL = { - getUid: () => null, - getToken: () => Promise.resolve(null), - addAuthTokenListener: (callback: (token: string | null) => void) => { - this.tokenListeners_.push(callback); - // Make sure callback is called, asynchronously, in the absence of the auth module - setTimeout(() => callback(null), 0); - }, - removeAuthTokenListener: callback => { - this.tokenListeners_ = this.tokenListeners_.filter( - listener => listener !== callback - ); - }, - analytics: { - logEvent() { - self.analyticsEventRequests_.push(arguments); - } - } - }; + this.container = new ComponentContainer(config.name!); + + // add itself to container + this._addComponent(new Component('app', () => this, ComponentType.PUBLIC)); + // populate ComponentContainer with existing components + for (const component of this.firebase_.INTERNAL.components.values()) { + this._addComponent(component); + } } get automaticDataCollectionEnabled(): boolean { @@ -112,23 +93,13 @@ export class FirebaseAppImpl implements FirebaseApp { }) .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 - .filter(service => 'INTERNAL' in service) - .map(service => service.INTERNAL!.delete()) + this.container.getProviders().map(provider => provider.delete()) ); }) .then((): void => { this.isDeleted_ = true; - this.services_ = {}; }); } @@ -152,28 +123,10 @@ export class FirebaseAppImpl implements FirebaseApp { ): 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]; + // getImmediate will always succeed because _getService is only called for registered components. + return (this.container.getProvider(name as Name).getImmediate({ + identifier: instanceIdentifier + }) as unknown) as FirebaseService; } /** * Remove a service instance from the cache, so we will create a new instance for this service @@ -189,48 +142,28 @@ export class FirebaseAppImpl implements FirebaseApp { name: string, instanceIdentifier: string = DEFAULT_ENTRY_NAME ): void { - if (this.services_[name] && this.services_[name][instanceIdentifier]) { - delete this.services_[name][instanceIdentifier]; - } + // eslint-disable-next-line @typescript-eslint/no-explicit-any + this.container.getProvider(name as any).clearInstance(instanceIdentifier); } /** - * Callback function used to extend an App instance at the time - * of service instance creation. + * @param component the component being added to this app's container */ - // eslint-disable-next-line @typescript-eslint/no-explicit-any - private extendApp(props: { [name: string]: any }): void { - // Copy the object onto the FirebaseAppImpl prototype - deepExtend(this, props); - - if (props.INTERNAL) { - /** - * If the app has overwritten the addAuthTokenListener stub, forward - * the active token listeners on to the true fxn. - * - * TODO: This function is required due to our current module - * structure. Once we are able to rely strictly upon a single module - * implementation, this code should be refactored and Auth should - * provide these stubs and the upgrade logic - */ - if (props.INTERNAL.addAuthTokenListener) { - for (const listener of this.tokenListeners_) { - this.INTERNAL.addAuthTokenListener(listener); - } - this.tokenListeners_ = []; - } - - if (props.INTERNAL.analytics) { - for (const request of this.analyticsEventRequests_) { - // logEvent is the actual implementation at this point. - // We forward the queued events to it. - this.INTERNAL.analytics.logEvent.apply(undefined, request); - } - this.analyticsEventRequests_ = []; - } + _addComponent(component: Component): void { + try { + this.container.addComponent(component); + } catch (e) { + logger.debug( + `Component ${component.name} failed to register with FirebaseApp ${this.name}`, + e + ); } } + _addOrOverwriteComponent(component: Component): void { + this.container.addOrOverwriteComponent(component); + } + /** * This function will throw an Error if the App has already been deleted - * use before performing API actions on the App. diff --git a/packages/app/src/firebaseNamespace.ts b/packages/app/src/firebaseNamespace.ts index 78ad157b1da..ef2528dbe6b 100644 --- a/packages/app/src/firebaseNamespace.ts +++ b/packages/app/src/firebaseNamespace.ts @@ -50,3 +50,5 @@ export function createFirebaseNamespace(): FirebaseNamespace { return namespace; } + +export const firebase = createFirebaseNamespace(); diff --git a/packages/app/src/firebaseNamespaceCore.ts b/packages/app/src/firebaseNamespaceCore.ts index 924f76288b8..3af8795ad1d 100644 --- a/packages/app/src/firebaseNamespaceCore.ts +++ b/packages/app/src/firebaseNamespaceCore.ts @@ -25,9 +25,7 @@ import { _FirebaseApp, _FirebaseNamespace, FirebaseService, - FirebaseServiceFactory, - FirebaseServiceNamespace, - AppHook + FirebaseServiceNamespace } from '@firebase/app-types/private'; import { deepExtend, contains } from '@firebase/util'; import { FirebaseAppImpl } from './firebaseApp'; @@ -36,6 +34,7 @@ import { FirebaseAppLiteImpl } from './lite/firebaseAppLite'; import { DEFAULT_ENTRY_NAME } from './constants'; import { version } from '../../firebase/package.json'; import { logger } from './logger'; +import { Component, ComponentType } from '@firebase/component'; /** * Because auth can't share code with other components, we attach the utility functions @@ -48,8 +47,8 @@ export function createFirebaseNamespaceCore( firebaseAppImpl: typeof FirebaseAppImpl | typeof FirebaseAppLiteImpl ): FirebaseNamespace { const apps: { [name: string]: FirebaseApp } = {}; - const factories: { [service: string]: FirebaseServiceFactory } = {}; - const appHooks: { [service: string]: AppHook } = {}; + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const components = new Map>(); // A namespace is a plain JavaScript Object. const namespace: FirebaseNamespace = { @@ -64,9 +63,9 @@ export function createFirebaseNamespaceCore( apps: null, SDK_VERSION: version, INTERNAL: { - registerService, + registerComponent, removeApp, - factories, + components, useAsService } }; @@ -94,8 +93,6 @@ export function createFirebaseNamespaceCore( * are deleted. */ function removeApp(name: string): void { - const app = apps[name]; - callAppHooks(app, 'delete'); delete apps[name]; } @@ -154,7 +151,6 @@ export function createFirebaseNamespaceCore( ); apps[name] = app; - callAppHooks(app, 'create'); return app; } @@ -167,93 +163,75 @@ export function createFirebaseNamespaceCore( 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 { - // If re-registering a service that already exists, return existing service - if (factories[name]) { - logger.debug(`There were multiple attempts to register service ${name}.`); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - return (namespace as any)[name] as FirebaseServiceNamespace< - FirebaseService - >; + function registerComponent( + component: Component + ): FirebaseServiceNamespace | null { + const componentName = component.name; + if (components.has(componentName)) { + logger.debug( + `There were multiple attempts to register component ${componentName}.` + ); + + return component.type === ComponentType.PUBLIC + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (namespace as any)[componentName] + : null; } - // 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); - }); - } + components.set(componentName, component); + + // create service namespace for public components + if (component.type === ComponentType.PUBLIC) { + // The Service namespace is an accessor function ... + const serviceNamespace = ( + appArg: FirebaseApp = app() + ): FirebaseService => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + if (typeof (appArg as any)[componentName] !== 'function') { + // Invalid argument. + // This happens in the following case: firebase.storage('gs:/') + throw ERROR_FACTORY.create(AppError.INVALID_APP_ARGUMENT, { + appName: componentName + }); + } + + // Forward service instance lookup to the FirebaseApp. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (appArg as any)[componentName](); + }; - // The Service namespace is an accessor function ... - function serviceNamespace(appArg: FirebaseApp = app()): FirebaseService { - // @ts-ignore - if (typeof appArg[name] !== 'function') { - // Invalid argument. - // This happens in the following case: firebase.storage('gs:/') - throw ERROR_FACTORY.create(AppError.INVALID_APP_ARGUMENT, { - appName: name - }); + // ... and a container for service-level properties. + if (component.serviceProps !== undefined) { + deepExtend(serviceNamespace, component.serviceProps); } - // Forward service instance lookup to the FirebaseApp. - // @ts-ignore - return appArg[name](); - } - - // ... and a container for service-level properties. - if (serviceProperties !== undefined) { - deepExtend(serviceNamespace, serviceProperties); - } - - // Monkey-patch the serviceNamespace onto the firebase namespace - // @ts-ignore - namespace[name] = serviceNamespace; - - // Patch the FirebaseAppImpl prototype - // @ts-ignore - firebaseAppImpl.prototype[name] = - // TODO: The eslint disable can be removed and the 'ignoreRestArgs' - // option added to the no-explicit-any rule when ESlint releases it. // eslint-disable-next-line @typescript-eslint/no-explicit-any - function(...args: any) { - const serviceFxn = this._getService.bind(this, name); - return serviceFxn.apply(this, allowMultipleInstances ? args : []); - }; - - return serviceNamespace; - } + (namespace as any)[componentName] = serviceNamespace; - function callAppHooks(app: FirebaseApp, eventName: string): void { - for (const serviceName of Object.keys(factories)) { - // Ignore virtual services - const factoryName = useAsService(app, serviceName); - if (factoryName === null) { - return; - } + // Patch the FirebaseAppImpl prototype + // eslint-disable-next-line @typescript-eslint/no-explicit-any + (firebaseAppImpl.prototype as any)[componentName] = + // TODO: The eslint disable can be removed and the 'ignoreRestArgs' + // option added to the no-explicit-any rule when ESlint releases it. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + function(...args: any) { + const serviceFxn = this._getService.bind(this, componentName); + return serviceFxn.apply( + this, + component.multipleInstances ? args : [] + ); + }; + } - if (appHooks[factoryName]) { - appHooks[factoryName](eventName, app); - } + // add the component to existing app instances + for (const appName of Object.keys(apps)) { + (apps[appName] as _FirebaseApp)._addComponent(component); } + + return component.type === ComponentType.PUBLIC + ? // eslint-disable-next-line @typescript-eslint/no-explicit-any + (namespace as any)[componentName] + : null; } // Map the requested service to a registered service name diff --git a/packages/app/src/lite/firebaseAppLite.ts b/packages/app/src/lite/firebaseAppLite.ts index 8e9349cbcd3..e19cd940252 100644 --- a/packages/app/src/lite/firebaseAppLite.ts +++ b/packages/app/src/lite/firebaseAppLite.ts @@ -25,9 +25,15 @@ import { _FirebaseNamespace, FirebaseService } from '@firebase/app-types/private'; -import { deepCopy, deepExtend } from '@firebase/util'; +import { deepCopy } from '@firebase/util'; import { ERROR_FACTORY, AppError } from '../errors'; import { DEFAULT_ENTRY_NAME } from '../constants'; +import { + ComponentContainer, + Component, + ComponentType, + Name +} from '@firebase/component'; interface ServicesCache { [name: string]: { @@ -43,8 +49,8 @@ export class FirebaseAppLiteImpl implements FirebaseApp { private readonly options_: FirebaseOptions; private readonly name_: string; private isDeleted_ = false; - private services_: ServicesCache = {}; private automaticDataCollectionEnabled_: boolean; + private container: ComponentContainer; // lite version has an empty INTERNAL namespace readonly INTERNAL = {}; @@ -58,6 +64,16 @@ export class FirebaseAppLiteImpl implements FirebaseApp { this.automaticDataCollectionEnabled_ = config.automaticDataCollectionEnabled || false; this.options_ = deepCopy(options); + this.container = new ComponentContainer(config.name!); + + // add itself to container + this.container.addComponent( + new Component('app', () => this, ComponentType.PUBLIC) + ); + // populate ComponentContainer with existing components + for (const component of this.firebase_.INTERNAL.components.values()) { + this.container.addComponent(component); + } } get automaticDataCollectionEnabled(): boolean { @@ -87,23 +103,13 @@ export class FirebaseAppLiteImpl implements FirebaseApp { }) .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 - .filter(service => 'INTERNAL' in service) - .map(service => service.INTERNAL!.delete()) + this.container.getProviders().map(provider => provider.delete()) ); }) .then((): void => { this.isDeleted_ = true; - this.services_ = {}; }); } @@ -127,37 +133,10 @@ export class FirebaseAppLiteImpl implements FirebaseApp { ): 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]: unknown }): void { - // Copy the object onto the FirebaseAppImpl prototype - deepExtend(this, props); + // getImmediate will always succeed because _getService is only called for registered components. + return (this.container.getProvider(name as Name).getImmediate({ + identifier: instanceIdentifier + }) as unknown) as FirebaseService; } /** diff --git a/packages/app/src/lite/firebaseNamespaceLite.ts b/packages/app/src/lite/firebaseNamespaceLite.ts index 1083db4c254..3b14081faaf 100644 --- a/packages/app/src/lite/firebaseNamespaceLite.ts +++ b/packages/app/src/lite/firebaseNamespaceLite.ts @@ -19,46 +19,40 @@ import { FirebaseNamespace } from '@firebase/app-types'; import { _FirebaseApp, _FirebaseNamespace, - FirebaseServiceFactory, - AppHook, FirebaseServiceNamespace, FirebaseService } from '@firebase/app-types/private'; import { FirebaseAppLiteImpl } from './firebaseAppLite'; import { createFirebaseNamespaceCore } from '../firebaseNamespaceCore'; +import { Component, ComponentType } from '@firebase/component'; export function createFirebaseNamespaceLite(): FirebaseNamespace { const namespace = createFirebaseNamespaceCore(FirebaseAppLiteImpl); namespace.SDK_VERSION = `${namespace.SDK_VERSION}_LITE`; - const registerService = (namespace as _FirebaseNamespace).INTERNAL - .registerService; - (namespace as _FirebaseNamespace).INTERNAL.registerService = registerServiceForLite; + const registerComponent = (namespace as _FirebaseNamespace).INTERNAL + .registerComponent; + (namespace as _FirebaseNamespace).INTERNAL.registerComponent = registerComponentForLite; /** * 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]: unknown }, - appHook?: AppHook, - allowMultipleInstances?: boolean - ): FirebaseServiceNamespace { + function registerComponentForLite( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + component: Component + ): FirebaseServiceNamespace | null { // only allow performance to register with firebase lite - if (name !== 'performance' && name !== 'installations') { + if ( + component.type === ComponentType.PUBLIC && + component.name !== 'performance' && + component.name !== 'installations' + ) { throw Error(`${name} cannot register with the standalone perf instance`); } - return registerService( - name, - createService, - serviceProperties, - appHook, - allowMultipleInstances - ); + return registerComponent(component); } return namespace; diff --git a/packages/app/test/firebaseApp.test.ts b/packages/app/test/firebaseApp.test.ts index 5cd4f80e020..80ccfb7b5aa 100644 --- a/packages/app/test/firebaseApp.test.ts +++ b/packages/app/test/firebaseApp.test.ts @@ -27,8 +27,10 @@ import { } from '@firebase/app-types/private'; import { createFirebaseNamespace } from '../src/firebaseNamespace'; import { createFirebaseNamespaceLite } from '../src/lite/firebaseNamespaceLite'; -import { assert } from 'chai'; +import { expect } from 'chai'; import { stub } from 'sinon'; +import { Component, ComponentType } from '@firebase/component'; +import './setup'; executeFirebaseTests(); executeFirebaseLiteTests(); @@ -42,297 +44,61 @@ function executeFirebaseTests(): void { beforeEach(() => { firebase = createFirebaseNamespace(); }); - it('Register App Hook', done => { - const events = ['create', 'delete']; - let hookEvents = 0; - (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(); - } - } - ); - const app = firebase.initializeApp({}); - // Ensure the hook is called synchronously - assert.equal(hookEvents, 1); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - app.delete(); - }); - - 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('Will do nothing if registerService is called again with the same name', () => { + it('will do nothing if registerComponent is called again with the same name', () => { const registerStub = stub( (firebase as _FirebaseNamespace).INTERNAL, - 'registerService' + 'registerComponent' ).callThrough(); - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => new TestService(app) + + const testComponent = createTestComponent('test'); + + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + testComponent ); firebase.initializeApp({}); const serviceNamespace = (firebase as any).test; - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => new TestService(app) + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + testComponent ); const serviceNamespace2 = (firebase as any).test; - assert.strictEqual(serviceNamespace, serviceNamespace2); - assert.doesNotThrow(registerStub); - }); - - 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); - } - ); - - assert.isDefined((app1 as any).lazyService); - - // Initial service registration happens on first invocation - assert.equal(registrations, 0); - - // Verify service has been registered - (firebase as any).lazyService(); - assert.equal(registrations, 1); - - // Service should only be created once - (firebase as any).lazyService(); - assert.equal(registrations, 1); - - // Service should only be created once... regardless of how you invoke the function - (firebase as any).lazyService(app1); - assert.equal(registrations, 1); - - // Service should already be defined for the second app - const app2 = firebase.initializeApp({}, 'second'); - assert.isDefined((app1 as any).lazyService); - - // Service still should not have registered for the second app - assert.equal(registrations, 1); - // Service should initialize once called - (app2 as any).lazyService(); - assert.equal(registrations, 2); + expect(serviceNamespace).to.eq(serviceNamespace2); + expect(registerStub).to.have.not.thrown(); }); - 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); - // eslint-disable-next-line @typescript-eslint/no-floating-promises - app.delete(); - }); - - 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); - }, - undefined, - undefined, - 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(`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); - }, - undefined, - undefined, - false // <-- multi instance flag - ); + it('returns cached service instances', () => { 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' + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('test') ); - 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({}); - }); + const service = (firebase as any).test(); - 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.isNotNull(token); - assert.equal('tokenFor0', token!.accessToken); - return (app2 as _FirebaseApp).INTERNAL.getToken(); - }) - .then(token => { - assert.isNotNull(token); - assert.equal('tokenFor1', token!.accessToken); - }); + expect(service).to.eq((firebase as any).test()); }); - it(`Should create a new instance of a service after removing the existing instance`, () => { + it(`creates a new instance of a service after removing the existing instance`, () => { const app = firebase.initializeApp({}); - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => { - return new TestService(app); - } + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('test') ); const service = (firebase as any).test(); - assert.equal(service, (firebase as any).test()); + expect(service).to.eq((firebase as any).test()); (app as _FirebaseApp)._removeServiceInstance('test'); - assert.notEqual(service, (firebase as any).test()); + expect(service, (firebase as any).test()); }); - it(`Should create a new instance of a service after removing the existing instance - for service that supports multiple instances`, () => { + it(`creates a new instance of a service after removing the existing instance - for service that supports multiple instances`, () => { const app = firebase.initializeApp({}); - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'multiInstance', - (...args) => { - const [app, , instanceIdentifier] = args; - return new TestService(app, instanceIdentifier); - }, - undefined, - undefined, - true + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('multiInstance', true) ); // default instance @@ -348,10 +114,9 @@ function executeFirebaseTests(): void { ); // default instance should not be changed - assert.equal(instance1, (firebase.app() as any).multiInstance()); + expect(instance1).to.eq((firebase.app() as any).multiInstance()); - assert.notEqual( - instance2, + expect(instance2).to.not.eq( (firebase.app() as any).multiInstance(serviceIdentifier) ); }); @@ -368,27 +133,38 @@ function executeFirebaseLiteTests(): void { firebase = createFirebaseNamespaceLite(); }); - it('should allow Performance service to register', () => { - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'performance', - (app: FirebaseApp) => { - return new TestService(app); - } + it('allows Performance service to register', () => { + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('performance') ); const app = firebase.initializeApp({}); const perf = (app as any).performance(); - assert.isTrue(perf instanceof TestService); + expect(perf).to.be.instanceof(TestService); }); - it('should NOT allow services other than Performance to register', () => { - assert.throws(() => { - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'test', - (app: FirebaseApp) => { - return new TestService(app); - } - ); - }); + it('allows Installations service to register', () => { + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('installations') + ); + const app = firebase.initializeApp({}); + const perf = (app as any).installations(); + expect(perf).to.be.instanceof(TestService); + }); + + it('does NOT allow services other than Performance and installations to register', () => { + expect(() => + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('auth') + ) + ).to.throw(); + }); + + it('allows any private component to register', () => { + expect(() => + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + createTestComponent('auth-internal', false, ComponentType.PRIVATE) + ) + ).to.not.throw(); }); }); } @@ -404,68 +180,65 @@ function firebaseAppTests( firebase = firebaseNamespaceFactory(); }); - it('No initial apps.', () => { - assert.equal(firebase.apps.length, 0); + it(' has no initial apps.', () => { + expect(firebase.apps.length).to.eq(0); + }); + + it('Can get app via firebase namespace.', () => { + const app = firebase.initializeApp({}); + expect(app).to.be.not.null; }); - it('Can initialize DEFAULT App.', () => { + 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); + expect(firebase.apps.length).to.eq(1); + expect(app).to.eq(firebase.apps[0]); + expect(app.name).to.eq('[DEFAULT]'); + expect(firebase.app()).to.eq(app); + expect(firebase.app('[DEFAULT]')).to.eq(app); }); - it('Can get options of App.', () => { + it('can get options of App.', () => { const options: FirebaseOptions = { projectId: 'projectId' }; const app = firebase.initializeApp(options); - assert.deepEqual(app.options, options); + expect(app.options).to.deep.eq(options); }); - it('Can delete App.', () => { + it('can delete App.', async () => { const app = firebase.initializeApp({}); - assert.equal(firebase.apps.length, 1); - return app.delete().then(() => { - assert.equal(firebase.apps.length, 0); - }); + expect(firebase.apps.length).to.eq(1); + await app.delete(); + expect(firebase.apps.length).to.eq(0); }); - it('Can create named App.', () => { + 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); + expect(firebase.apps.length).to.eq(1); + expect(app.name).to.eq('my-app'); + expect(firebase.app('my-app')).to.eq(app); }); - it('Can create named App and DEFAULT app.', () => { + it('can create named App and DEFAULT app.', () => { firebase.initializeApp({}, 'my-app'); - assert.equal(firebase.apps.length, 1); + expect(firebase.apps.length).to.eq(1); firebase.initializeApp({}); - assert.equal(firebase.apps.length, 2); + expect(firebase.apps.length).to.eq(2); }); - it('Can get app via firebase namespace.', () => { + it('duplicate DEFAULT initialize is an error.', () => { firebase.initializeApp({}); + expect(() => firebase.initializeApp({})).throws(/\[DEFAULT\].*exists/i); }); - it('Duplicate DEFAULT initialize is an error.', () => { - firebase.initializeApp({}); - assert.throws(() => { - firebase.initializeApp({}); - }, /\[DEFAULT\].*exists/i); - }); - - it('Duplicate named App initialize is an error.', () => { + it('duplicate named App initialize is an error.', () => { firebase.initializeApp({}, 'abc'); - assert.throws(() => { - firebase.initializeApp({}, 'abc'); - }, /'abc'.*exists/i); + + expect(() => firebase.initializeApp({}, 'abc')).throws(/'abc'.*exists/i); }); it('automaticDataCollectionEnabled is `false` by default', () => { const app = firebase.initializeApp({}, 'my-app'); - assert.equal(app.automaticDataCollectionEnabled, false); + expect(app.automaticDataCollectionEnabled).to.eq(false); }); it('automaticDataCollectionEnabled can be set via the config object', () => { @@ -473,7 +246,7 @@ function firebaseAppTests( {}, { automaticDataCollectionEnabled: true } ); - assert.equal(app.automaticDataCollectionEnabled, true); + expect(app.automaticDataCollectionEnabled).to.eq(true); }); it('Modifying options object does not change options.', () => { @@ -484,63 +257,57 @@ function firebaseAppTests( firebase.initializeApp(options); options.appId = 'changed'; delete options.measurementId; - assert.deepEqual(firebase.app().options, { + expect(firebase.app().options).to.deep.eq({ appId: 'original', measurementId: 'someId' }); }); - it('Error to use app after it is deleted.', () => { + it('Error to use app after it is deleted.', async () => { const app = firebase.initializeApp({}); - return app.delete().then(() => { - assert.throws(() => { - console.log(app.name); - }, /already.*deleted/); - }); + await app.delete(); + expect(() => console.log(app.name)).throws(/already.*deleted/); }); - it('OK to create same-name app after it is deleted.', () => { + it('OK to create same-name app after it is deleted.', async () => { 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(() => { - console.log(app.name); - }, /already.*deleted/); - }); + await app.delete(); + + const app2 = firebase.initializeApp({}, 'app-name'); + expect(app).to.not.eq(app2, 'Expect new instance.'); + // But original app id still orphaned. + expect(() => console.log(app.name)).throws(/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); + expect(firebase.apps.length).to.eq(1); + expect(app.name).to.eq('toString'); + expect(firebase.app('toString')).to.eq(app); }); it('Error to get uninitialized app using Object.prototype member name.', () => { - assert.throws(() => { - firebase.app('toString'); - }, /'toString'.*created/i); + expect(() => firebase.app('toString')).throws(/'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); + expect(() => firebase.initializeApp({}, data as string)).throws( + /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); + expect(() => + firebase.initializeApp({}, { name: name as string }) + ).throws(/Illegal app name/i); }); } }); @@ -562,3 +329,18 @@ class TestService implements FirebaseService { }); } } + +function createTestComponent( + name: string, + multiInstances = false, + type = ComponentType.PUBLIC +): Component { + const component = new Component( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + name as any, + container => new TestService(container.getProvider('app').getImmediate()), + type + ); + component.setMultipleInstances(multiInstances); + return component; +} diff --git a/packages/app/test/setup.ts b/packages/app/test/setup.ts new file mode 100644 index 00000000000..e1d5d7c35b7 --- /dev/null +++ b/packages/app/test/setup.ts @@ -0,0 +1,26 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { use } from 'chai'; +import { restore } from 'sinon'; +import * as sinonChai from 'sinon-chai'; + +use(sinonChai); + +afterEach(async () => { + restore(); +}); diff --git a/packages/app/tsconfig.json b/packages/app/tsconfig.json index f2942111423..72e0736c2b7 100644 --- a/packages/app/tsconfig.json +++ b/packages/app/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../config/tsconfig.base.json", "compilerOptions": { "outDir": "dist", - "resolveJsonModule": true + "resolveJsonModule": true, + "downlevelIteration": true }, "exclude": ["dist/**/*"] } diff --git a/packages/component/.eslintrc.json b/packages/component/.eslintrc.json new file mode 100644 index 00000000000..2b39e9f6f85 --- /dev/null +++ b/packages/component/.eslintrc.json @@ -0,0 +1,6 @@ +{ + "extends": "../../config/.eslintrc.json", + "parserOptions": { + "project": "tsconfig.json" + } +} \ No newline at end of file diff --git a/packages/component/README.md b/packages/component/README.md new file mode 100644 index 00000000000..cade170b435 --- /dev/null +++ b/packages/component/README.md @@ -0,0 +1,20 @@ +# @firebase/component + +_NOTE: This is specifically tailored for Firebase JS SDK usage, if you are not a +member of the Firebase team, please avoid using this package_ + +## Installation + +You can install this wrapper by running the following in your project: + +```bash +$ npm install @firebase/component +``` + +## Usage + +**ES Modules** + +```javascript +import { Component } from '@firebase/component'; +``` diff --git a/packages/component/index.ts b/packages/component/index.ts new file mode 100644 index 00000000000..3fb81c71e25 --- /dev/null +++ b/packages/component/index.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export { Component } from './src/component'; +export { ComponentContainer } from './src/component_container'; +export { Provider } from './src/provider'; +export { + ComponentType, + InstanceFactory, + InstantiationMode, + NameServiceMapping, + Name +} from './src/types'; diff --git a/packages/component/karma.conf.js b/packages/component/karma.conf.js new file mode 100644 index 00000000000..9da71b4327f --- /dev/null +++ b/packages/component/karma.conf.js @@ -0,0 +1,36 @@ +/** + * @license + * Copyright 2017 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +const karma = require('karma'); +const path = require('path'); +const karmaBase = require('../../config/karma.base'); + +const files = ['src/**/*.test.ts']; + +module.exports = function(config) { + config.set({ + ...karmaBase, + // files to load into karma + files: files, + preprocessors: { '**/*.ts': ['webpack', 'sourcemap'] }, + // frameworks to use + // available frameworks: https://npmjs.org/browse/keyword/karma-adapter + frameworks: ['mocha'] + }); +}; + +module.exports.files = files; diff --git a/packages/component/package.json b/packages/component/package.json new file mode 100644 index 00000000000..37190271fa8 --- /dev/null +++ b/packages/component/package.json @@ -0,0 +1,76 @@ +{ + "name": "@firebase/component", + "version": "0.1.0", + "private": true, + "description": "Firebase Component Platform", + "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": { + "lint": "eslint -c .eslintrc.json '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.json '**/*.ts' --ignore-path '../../.gitignore'", + "build": "rollup -c", + "dev": "rollup -c -w", + "test": "run-p lint test:browser test:node", + "test:browser": "karma start --single-run", + "test:node": "TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha src/**/*.test.ts --opts ../../config/mocha.node.opts", + "prepare": "yarn build" + }, + "dependencies": { + "@firebase/util": "0.2.31", + "tslib": "1.10.0" + }, + "license": "Apache-2.0", + "devDependencies": { + "@types/chai": "4.2.4", + "@types/mocha": "5.2.7", + "@types/sinon": "7.5.0", + "chai": "4.2.0", + "chai-as-promised": "7.1.1", + "karma": "4.4.1", + "sinon": "7.5.0", + "sinon-chai": "3.3.0", + "karma-chrome-launcher": "3.1.0", + "karma-cli": "2.0.0", + "karma-coverage-istanbul-reporter": "2.1.0", + "karma-firefox-launcher": "1.2.0", + "karma-mocha": "1.3.0", + "karma-sauce-launcher": "1.2.0", + "karma-spec-reporter": "0.0.32", + "karma-webpack": "4.0.2", + "mocha": "6.2.2", + "npm-run-all": "4.1.5", + "nyc": "14.1.1", + "rollup": "1.25.2", + "rollup-plugin-typescript2": "0.24.3", + "ts-loader": "6.2.1", + "ts-node": "8.4.1", + "typescript": "3.6.4", + "webpack": "4.41.2", + "eslint": "6.5.1", + "@typescript-eslint/parser": "2.5.0", + "@typescript-eslint/eslint-plugin": "2.5.0", + "@typescript-eslint/eslint-plugin-tslint": "2.5.0", + "eslint-plugin-import": "2.18.2" + }, + "repository": { + "directory": "packages/component", + "type": "git", + "url": "https://github.com/firebase/firebase-js-sdk.git" + }, + "bugs": { + "url": "https://github.com/firebase/firebase-js-sdk/issues" + }, + "typings": "dist/index.d.ts", + "nyc": { + "extension": [ + ".ts" + ], + "reportDir": "./coverage/node" + } +} diff --git a/packages/component/rollup.config.js b/packages/component/rollup.config.js new file mode 100644 index 00000000000..8cfe95d6751 --- /dev/null +++ b/packages/component/rollup.config.js @@ -0,0 +1,80 @@ +/** + * @license + * 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 typescript from 'typescript'; +import pkg from './package.json'; + +const deps = Object.keys( + Object.assign({}, pkg.peerDependencies, pkg.dependencies) +); + +/** + * ES5 Builds + */ +const es5BuildPlugins = [ + typescriptPlugin({ + typescript + }) +]; + +const es5Builds = [ + /** + * Browser Builds + */ + { + input: 'index.ts', + output: [ + { file: pkg.browser, format: 'cjs', sourcemap: true }, + { file: pkg.module, format: 'es', sourcemap: true } + ], + plugins: es5BuildPlugins, + external: id => deps.some(dep => id === dep || id.startsWith(`${dep}/`)) + } +]; + +/** + * ES2017 Builds + */ +const es2017BuildPlugins = [ + typescriptPlugin({ + typescript, + tsconfigOverride: { + compilerOptions: { + target: 'es2017' + } + } + }) +]; + +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/component/src/component.ts b/packages/component/src/component.ts new file mode 100644 index 00000000000..896aa6f3ec1 --- /dev/null +++ b/packages/component/src/component.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 { + InstantiationMode, + InstanceFactory, + ComponentType, + Dictionary, + Name +} from './types'; + +/** + * Component for service name T, e.g. `auth`, `auth-internal` + */ +export class Component { + multipleInstances = false; + /** + * Properties to be added to the service namespace + */ + serviceProps: Dictionary = {}; + + instantiationMode = InstantiationMode.LAZY; + + /** + * + * @param name The public service name, e.g. app, auth, firestore, database + * @param instanceFactory Service factory responsible for creating the public interface + * @param type whehter the service provided by the component is public or private + */ + constructor( + readonly name: T, + readonly instanceFactory: InstanceFactory, + readonly type: ComponentType + ) {} + + setInstantiationMode(mode: InstantiationMode): this { + this.instantiationMode = mode; + return this; + } + + setMultipleInstances(multipleInstances: boolean): this { + this.multipleInstances = multipleInstances; + return this; + } + + setServiceProps(props: Dictionary): this { + this.serviceProps = props; + return this; + } +} diff --git a/packages/component/src/component_container.test.ts b/packages/component/src/component_container.test.ts new file mode 100644 index 00000000000..2df34f631cb --- /dev/null +++ b/packages/component/src/component_container.test.ts @@ -0,0 +1,130 @@ +/** + * @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 { ComponentContainer } from './component_container'; +import '../test/setup'; +import { Provider } from './provider'; +import { InstantiationMode } from './types'; +import { DEFAULT_ENTRY_NAME } from './constants'; +import { getFakeComponent } from '../test/util'; + +// extend NameServiceMapping with the service names we are going to use in the tests +// It is because ComponentContainer.getProvider is strongly typed, and it can only be called +// with a field name present in NameServiceMapping interface. +declare module './types' { + interface NameServiceMapping { + rocket: {}; + ship: {}; + fireball: {}; + } +} + +describe('Component Container', () => { + let container: ComponentContainer; + beforeEach(() => { + container = new ComponentContainer(DEFAULT_ENTRY_NAME); + }); + + it('returns a service provider given a name', () => { + expect(container.getProvider('rocket')).to.be.an.instanceof(Provider); + }); + + it('returns the same provider instance for the same name', () => { + const provider1 = container.getProvider('ship'); + const provider2 = container.getProvider('ship'); + + expect(provider1).to.equal(provider2); + }); + + it('calls setComponent() on provider with the same name when registering a component', () => { + const provider = container.getProvider('fireball'); + const setComponentStub = stub(provider, 'setComponent').callThrough(); + const component = getFakeComponent( + 'fireball', + () => ({ test: 1 }), + true, + InstantiationMode.EAGER + ); + container.addComponent(component); + + expect(setComponentStub).has.been.calledWith(component); + }); + + it('throws when registering multiple components with the same name, when overwrite is false', () => { + const component1 = getFakeComponent( + 'fireball', + () => ({}), + true, + InstantiationMode.EAGER + ); + const component2 = getFakeComponent( + 'fireball', + () => ({ test: true }), + false, + InstantiationMode.LAZY + ); + + expect(() => container.addComponent(component1)).to.not.throw(); + expect(() => container.addComponent(component2)).to.throw( + /Component fireball has already been registered with/ + ); + }); + + it('does not throw when registering multiple components with the same name, when overwrite is true', () => { + const component1 = getFakeComponent( + 'fireball', + () => ({}), + true, + InstantiationMode.EAGER + ); + const component2 = getFakeComponent( + 'fireball', + () => ({ test: true }), + false, + InstantiationMode.LAZY + ); + + expect(() => container.addComponent(component1)).to.not.throw(); + expect(() => container.addOrOverwriteComponent(component2)).to.not.throw(); + }); + + it('registers a component with a name that is already registered and return the provider for the new component', () => { + const component1 = getFakeComponent( + 'fireball', + () => ({ test: false }), + true, + InstantiationMode.EAGER + ); + const component2 = getFakeComponent( + 'fireball', + () => ({ test: true }), + false, + InstantiationMode.LAZY + ); + + container.addComponent(component1); + const oldProvider = container.getProvider('fireball'); + expect(oldProvider.getImmediate()).to.deep.eq({ test: false }); + + container.addOrOverwriteComponent(component2); + const newProvider = container.getProvider('fireball'); + expect(oldProvider).to.not.eq(newProvider); + expect(newProvider.getImmediate()).to.deep.eq({ test: true }); + }); +}); diff --git a/packages/component/src/component_container.ts b/packages/component/src/component_container.ts new file mode 100644 index 00000000000..f20cd415567 --- /dev/null +++ b/packages/component/src/component_container.ts @@ -0,0 +1,82 @@ +/** + * @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 { Provider } from './provider'; +import { Component } from './component'; +import { Name } from './types'; + +/** + * ComponentContainer that provides Providers for service name T, e.g. `auth`, `auth-internal` + */ +export class ComponentContainer { + private readonly providers = new Map>(); + + constructor(private readonly name: string) {} + + /** + * + * @param component Component being added + * @param overwrite When a component with the same name has already been registered, + * if overwrite is true: overwrite the existing component with the new component and create a new + * provider with the new component. It can be useful in tests where you want to use different mocks + * for different tests. + * if overwrite is false: throw an exception + */ + addComponent(component: Component): void { + const provider = this.getProvider(component.name); + if (provider.isComponentSet()) { + throw new Error( + `Component ${component.name} has already been registered with ${this.name}` + ); + } + + provider.setComponent(component); + } + + addOrOverwriteComponent(component: Component): void { + const provider = this.getProvider(component.name); + if (provider.isComponentSet()) { + // delete the existing provider from the container, so we can register the new component + this.providers.delete(component.name); + } + + this.addComponent(component); + } + + /** + * getProvider provides a type safe interface where it can only be called with a field name + * present in NameServiceMapping interface. + * + * Firebase SDKs providing services should extend NameServiceMapping interface to register + * themselves. + */ + getProvider(name: T): Provider { + if (this.providers.has(name)) { + return this.providers.get(name) as Provider; + } + + // create a Provider for a service that hasn't registered with Firebase + const provider = new Provider(name, this); + this.providers.set(name, provider); + + return provider as Provider; + } + + getProviders(): Array> { + return Array.from(this.providers.values()); + } +} diff --git a/packages/component/src/constants.ts b/packages/component/src/constants.ts new file mode 100644 index 00000000000..2c6101879ea --- /dev/null +++ b/packages/component/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/component/src/provider.test.ts b/packages/component/src/provider.test.ts new file mode 100644 index 00000000000..80b5168df27 --- /dev/null +++ b/packages/component/src/provider.test.ts @@ -0,0 +1,428 @@ +/** + * @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 { fake, SinonSpy } from 'sinon'; +import { ComponentContainer } from './component_container'; +import { FirebaseService } from '@firebase/app-types/private'; +import { Provider } from './provider'; +import { getFakeApp, getFakeComponent } from '../test/util'; +import '../test/setup'; +import { InstantiationMode } from './types'; + +// define the types for the fake services we use in the tests +declare module './types' { + interface NameServiceMapping { + test: {}; + badtest: {}; + } +} + +describe('Provider', () => { + let provider: Provider<'test'>; + + beforeEach(() => { + provider = new Provider('test', new ComponentContainer('test-container')); + }); + + it('throws if setComponent() is called with a component with a different name than the provider name', () => { + expect(() => + // eslint-disable-next-line @typescript-eslint/no-explicit-any + provider.setComponent(getFakeComponent('badtest', () => ({})) as any) + ).to.throw(/^Mismatching Component/); + expect(() => + provider.setComponent(getFakeComponent('test', () => ({}))) + ).to.not.throw(); + }); + + it('does not throw if instance factory throws when calling getImmediate() with optional flag', () => { + provider.setComponent( + getFakeComponent('test', () => { + throw Error('something went wrong!'); + }) + ); + expect(() => provider.getImmediate({ optional: true })).to.not.throw(); + }); + + it('throws if instance factory throws when calling getImmediate() without optional flag', () => { + provider.setComponent( + getFakeComponent('test', () => { + throw Error('something went wrong!'); + }) + ); + expect(() => provider.getImmediate()).to.throw(); + }); + + it('does not throw if instance factory throws when calling get()', () => { + provider.setComponent( + getFakeComponent('test', () => { + throw Error('something went wrong!'); + }) + ); + expect(() => provider.get()).to.not.throw(); + }); + + it('does not throw if instance factory throws when registering an eager component', () => { + const eagerComponent = getFakeComponent( + 'test', + () => { + throw Error('something went wrong!'); + }, + false, + InstantiationMode.EAGER + ); + + expect(() => provider.setComponent(eagerComponent)).to.not.throw(); + }); + + it('does not throw if instance factory throws when registering a component with a pending promise', () => { + // create a pending promise + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.get(); + const component = getFakeComponent('test', () => { + throw Error('something went wrong!'); + }); + expect(() => provider.setComponent(component)).to.not.throw(); + }); + + describe('Provider (multipleInstances = false)', () => { + describe('getImmediate()', () => { + it('throws if the service is not available', () => { + expect(provider.getImmediate.bind(provider)).to.throw( + 'Service test is not available' + ); + }); + + it('returns null if the service is not available with optional flag', () => { + expect(provider.getImmediate({ optional: true })).to.equal(null); + }); + + it('returns the service instance synchronously', () => { + provider.setComponent(getFakeComponent('test', () => ({ test: true }))); + expect(provider.getImmediate()).to.deep.equal({ test: true }); + }); + + it('returns the cached service instance', () => { + provider.setComponent(getFakeComponent('test', () => ({ test: true }))); + const service1 = provider.getImmediate(); + const service2 = provider.getImmediate(); + expect(service1).to.equal(service2); + }); + + it('ignores parameter identifier and return the default service', () => { + provider.setComponent(getFakeComponent('test', () => ({ test: true }))); + const defaultService = provider.getImmediate(); + expect(provider.getImmediate({ identifier: 'spider1' })).to.equal( + defaultService + ); + expect(provider.getImmediate({ identifier: 'spider2' })).to.equal( + defaultService + ); + }); + }); + + describe('get()', () => { + it('get the service instance asynchronouly', async () => { + provider.setComponent(getFakeComponent('test', () => ({ test: true }))); + await expect(provider.get()).to.eventually.deep.equal({ test: true }); + }); + + it('ignore parameter identifier and return the default service instance asyn', async () => { + provider.setComponent(getFakeComponent('test', () => ({ test: true }))); + const defaultService = provider.getImmediate(); + await expect(provider.get('spider1')).to.eventually.equal( + defaultService + ); + await expect(provider.get('spider2')).to.eventually.equal( + defaultService + ); + }); + }); + + describe('provideFactory()', () => { + it('instantiates the service if there is a pending promise and the service is eager', () => { + // create a pending promise + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.get(); + + provider.setComponent( + getFakeComponent('test', () => ({}), false, InstantiationMode.EAGER) + ); + expect((provider as any).instances.size).to.equal(1); + }); + + it('instantiates the service if there is a pending promise and the service is NOT eager', () => { + // create a pending promise + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.get(); + + provider.setComponent(getFakeComponent('test', () => ({}))); + expect((provider as any).instances.size).to.equal(1); + }); + + it('instantiates the service if there is no pending promise and the service is eager', () => { + provider.setComponent( + getFakeComponent('test', () => ({}), false, InstantiationMode.EAGER) + ); + expect((provider as any).instances.size).to.equal(1); + }); + + it('does NOT instantiate the service if there is no pending promise and the service is not eager', () => { + provider.setComponent(getFakeComponent('test', () => ({}))); + expect((provider as any).instances.size).to.equal(0); + }); + + it('instantiates only the default service even if there are pending promises with identifiers', async () => { + // create a pending promise with identifiers. + const promise1 = provider.get('name1'); + const promise2 = provider.get('name2'); + + provider.setComponent(getFakeComponent('test', () => ({}))); + expect((provider as any).instances.size).to.equal(1); + + const defaultService = provider.getImmediate(); + + await expect(promise1).to.eventually.equal(defaultService); + await expect(promise2).to.eventually.equal(defaultService); + }); + }); + + describe('delete()', () => { + it('calls delete() on the service instance that implements FirebaseService', () => { + const deleteFake = fake(); + const myService: FirebaseService = { + app: getFakeApp(), + INTERNAL: { + delete: deleteFake + } + }; + + // provide factory and create a service instance + provider.setComponent( + getFakeComponent( + 'test', + () => myService, + false, + InstantiationMode.EAGER + ) + ); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.delete(); + + expect(deleteFake).to.have.been.called; + }); + }); + + describe('clearCache()', () => { + it('removes the service instance from cache', () => { + provider.setComponent(getFakeComponent('test', () => ({}))); + // create serviec instance + const instance = provider.getImmediate(); + expect((provider as any).instances.size).to.equal(1); + + provider.clearInstance(); + expect((provider as any).instances.size).to.equal(0); + + // get a new instance after cache has been cleared + const newInstance = provider.getImmediate(); + expect(newInstance).to.not.eq(instance); + }); + }); + }); + + describe('Provider (multipleInstances = true)', () => { + describe('getImmediate(identifier)', () => { + it('throws if the service is not available', () => { + expect( + provider.getImmediate.bind(provider, { identifier: 'guardian' }) + ).to.throw(); + }); + + it('returns null if the service is not available with optional flag', () => { + expect( + provider.getImmediate({ identifier: 'guardian', optional: true }) + ).to.equal(null); + }); + + it('returns different service instances for different identifiers synchronously', () => { + provider.setComponent( + getFakeComponent('test', () => ({ test: true }), true) + ); + const defaultService = provider.getImmediate(); + const service1 = provider.getImmediate({ identifier: 'guardian' }); + const service2 = provider.getImmediate({ identifier: 'servant' }); + + expect(defaultService).to.deep.equal({ test: true }); + expect(service1).to.deep.equal({ test: true }); + expect(service2).to.deep.equal({ test: true }); + expect(defaultService).to.not.equal(service1); + expect(defaultService).to.not.equal(service2); + expect(service1).to.not.equal(service2); + }); + }); + + describe('get(identifier)', () => { + it('returns different service instances for different identifiers asynchronouly', async () => { + provider.setComponent( + getFakeComponent('test', () => ({ test: true }), true) + ); + + const defaultService = await provider.get(); + const service1 = await provider.get('name1'); + const service2 = await provider.get('name2'); + + expect(defaultService).to.deep.equal({ test: true }); + expect(service1).to.deep.equal({ test: true }); + expect(service2).to.deep.equal({ test: true }); + expect(defaultService).to.not.equal(service1); + expect(defaultService).to.not.equal(service2); + expect(service1).to.not.equal(service2); + }); + }); + + describe('provideFactory()', () => { + it('instantiates services for the pending promises for all instance identifiers', async () => { + /* eslint-disable @typescript-eslint/no-floating-promises */ + // create 3 promises for 3 different identifiers + provider.get(); + provider.get('name1'); + provider.get('name2'); + /* eslint-enable @typescript-eslint/no-floating-promises */ + + provider.setComponent( + getFakeComponent('test', () => ({ test: true }), true) + ); + + expect((provider as any).instances.size).to.equal(3); + }); + + it('instantiates the default service if there is no pending promise and the service is eager', () => { + provider.setComponent( + getFakeComponent( + 'test', + () => ({ test: true }), + true, + InstantiationMode.EAGER + ) + ); + expect((provider as any).instances.size).to.equal(1); + }); + + it(`instantiates the default serviec if there are pending promises for other identifiers + but not for the default identifer and the service is eager`, () => { + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.get('name1'); + provider.setComponent( + getFakeComponent( + 'test', + () => ({ test: true }), + true, + InstantiationMode.EAGER + ) + ); + + expect((provider as any).instances.size).to.equal(2); + }); + }); + + describe('delete()', () => { + it('calls delete() on all service instances that implement FirebaseService', () => { + const deleteFakes: SinonSpy[] = []; + + function getService(): FirebaseService { + const deleteFake = fake(); + deleteFakes.push(deleteFake); + return { + app: getFakeApp(), + INTERNAL: { + delete: deleteFake + } + }; + } + + // provide factory that produces mulitpleInstances + provider.setComponent(getFakeComponent('test', getService, true)); + + // create 2 service instances with different names + provider.getImmediate({ identifier: 'instance1' }); + provider.getImmediate({ identifier: 'instance2' }); + + // eslint-disable-next-line @typescript-eslint/no-floating-promises + provider.delete(); + + expect(deleteFakes.length).to.equal(2); + for (const f of deleteFakes) { + expect(f).to.have.been.called; + } + }); + }); + describe('clearCache()', () => { + it('returns new service instances sync after cache is cleared', () => { + provider.setComponent(getFakeComponent('test', () => ({}), true)); + // create serviec instances with different identifiers + const defaultInstance = provider.getImmediate(); + const instance1 = provider.getImmediate({ identifier: 'instance1' }); + + expect((provider as any).instances.size).to.equal(2); + + // remove the default instance from cache and create a new default instance + provider.clearInstance(); + expect((provider as any).instances.size).to.equal(1); + const newDefaultInstance = provider.getImmediate(); + expect(newDefaultInstance).to.not.eq(defaultInstance); + expect((provider as any).instances.size).to.equal(2); + + // remove the named instance from cache and create a new instance with the same identifier + provider.clearInstance('instance1'); + expect((provider as any).instances.size).to.equal(1); + const newInstance1 = provider.getImmediate({ identifier: 'instance1' }); + expect(newInstance1).to.not.eq(instance1); + expect((provider as any).instances.size).to.equal(2); + }); + + it('returns new services asynchronously after cache is cleared', async () => { + provider.setComponent(getFakeComponent('test', () => ({}), true)); + // create serviec instances with different identifiers + const defaultInstance = await provider.get(); + const instance1 = await provider.get('instance1'); + + expect((provider as any).instances.size).to.equal(2); + expect((provider as any).instancesDeferred.size).to.equal(2); + + // remove the default instance from cache and create a new default instance + provider.clearInstance(); + expect((provider as any).instances.size).to.equal(1); + expect((provider as any).instancesDeferred.size).to.equal(1); + + const newDefaultInstance = await provider.get(); + expect(newDefaultInstance).to.not.eq(defaultInstance); + expect((provider as any).instances.size).to.equal(2); + expect((provider as any).instancesDeferred.size).to.equal(2); + + // remove the named instance from cache and create a new instance with the same identifier + provider.clearInstance('instance1'); + expect((provider as any).instances.size).to.equal(1); + expect((provider as any).instancesDeferred.size).to.equal(1); + const newInstance1 = await provider.get('instance1'); + expect(newInstance1).to.not.eq(instance1); + expect((provider as any).instances.size).to.equal(2); + expect((provider as any).instancesDeferred.size).to.equal(2); + }); + }); + }); +}); diff --git a/packages/component/src/provider.ts b/packages/component/src/provider.ts new file mode 100644 index 00000000000..063d4951805 --- /dev/null +++ b/packages/component/src/provider.ts @@ -0,0 +1,213 @@ +/** + * @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 { Deferred } from '@firebase/util'; +import { ComponentContainer } from './component_container'; +import { DEFAULT_ENTRY_NAME } from './constants'; +import { InstantiationMode, Name, NameServiceMapping } from './types'; +import { Component } from './component'; + +/** + * Provider for instance for service name T, e.g. 'auth', 'auth-internal' + * NameServiceMapping[T] is an alias for the type of the instance + */ +export class Provider { + private component: Component | null = null; + private readonly instances: Map = new Map(); + private readonly instancesDeferred: Map< + string, + Deferred + > = new Map(); + + constructor( + private readonly name: T, + private readonly container: ComponentContainer + ) {} + + /** + * @param identifier A provider can provide mulitple instances of a service + * if this.component.multipleInstances is true. + */ + get(identifier: string = DEFAULT_ENTRY_NAME): Promise { + // if multipleInstances is not supported, use the default name + const normalizedIdentifier = this.normalizeInstanceIdentifier(identifier); + + if (!this.instancesDeferred.has(normalizedIdentifier)) { + const deferred = new Deferred(); + this.instancesDeferred.set(normalizedIdentifier, deferred); + // If the service instance is available, resolve the promise with it immediately + try { + const instance = this.getOrInitializeService(normalizedIdentifier); + if (instance) { + deferred.resolve(instance); + } + } catch (e) { + // when the instance factory throws an exception during get(), it should not cause + // an fatal error. We just return the unresolved promise in this case. + } + } + + return this.instancesDeferred.get(normalizedIdentifier)!.promise; + } + + /** + * + * @param options.identifier A provider can provide mulitple instances of a service + * if this.component.multipleInstances is true. + * @param options.optional If optional is false or not provided, the method throws an error when + * the service is not immediately available. + * If optional is true, the method returns null if the service is not immediately available. + */ + getImmediate(options: { + identifier?: string; + optional: true; + }): NameServiceMapping[T] | null; + getImmediate(options?: { + identifier?: string; + optional?: false; + }): NameServiceMapping[T]; + getImmediate(options?: { + identifier?: string; + optional?: boolean; + }): NameServiceMapping[T] | null { + const { identifier, optional } = { + identifier: DEFAULT_ENTRY_NAME, + optional: false, + ...options + }; + // if multipleInstances is not supported, use the default name + const normalizedIdentifier = this.normalizeInstanceIdentifier(identifier); + try { + const instance = this.getOrInitializeService(normalizedIdentifier); + + if (!instance) { + if (optional) { + return null; + } + throw Error(`Service ${this.name} is not available`); + } + + return instance; + } catch (e) { + if (optional) { + return null; + } else { + throw e; + } + } + } + + setComponent(component: Component): void { + if (component.name !== this.name) { + throw Error( + `Mismatching Component ${component.name} for Provider ${this.name}.` + ); + } + + if (this.component) { + throw Error(`Component for ${this.name} has already been provided`); + } + + this.component = component; + // if the service is eager, initialize the default instance + if (isComponentEager(component)) { + try { + this.getOrInitializeService(DEFAULT_ENTRY_NAME); + } catch (e) { + // when the instance factory for an eager Component throws an exception during the eager + // initialization, it should not cause an fatal error. + // TODO: Investigate if we need to make it configurable, because some component may want to cause + // a fatal error in this case? + } + } + + // Create service instances for the pending promises and resolve them + // NOTE: if this.multipleInstances is false, only the default instance will be created + // and all promises with resolve with it regardless of the identifier. + for (const [ + instanceIdentifier, + instanceDeferred + ] of this.instancesDeferred.entries()) { + const normalizedIdentifier = this.normalizeInstanceIdentifier( + instanceIdentifier + ); + + try { + // `getOrInitializeService()` should always return a valid instance since a component is guaranteed. use ! to make typescript happy. + const instance = this.getOrInitializeService(normalizedIdentifier)!; + instanceDeferred.resolve(instance); + } catch (e) { + // when the instance factory throws an exception, it should not cause + // an fatal error. We just leave the promise unresolved. + } + } + } + + clearInstance(identifier: string = DEFAULT_ENTRY_NAME): void { + this.instancesDeferred.delete(identifier); + this.instances.delete(identifier); + } + + // app.delete() will call this method on every provider to delete the services + // TODO: should we mark the provider as deleted? + async delete(): Promise { + const services = Array.from(this.instances.values()); + + await Promise.all( + services + .filter(service => 'INTERNAL' in service) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map(service => (service as any).INTERNAL!.delete()) + ); + } + + isComponentSet(): boolean { + return this.component != null; + } + + private getOrInitializeService( + identifier: string + ): NameServiceMapping[T] | null { + let instance = this.instances.get(identifier); + if (!instance && this.component) { + instance = this.component.instanceFactory( + this.container, + normalizeIdentifierForFactory(identifier) + ) as NameServiceMapping[T]; + this.instances.set(identifier, instance); + } + + return instance || null; + } + + private normalizeInstanceIdentifier(identifier: string): string { + if (this.component) { + return this.component.multipleInstances ? identifier : DEFAULT_ENTRY_NAME; + } else { + return identifier; // assume multiple instances are supported before the component is provided. + } + } +} + +// undefined should be passed to the service factory for the default instance +function normalizeIdentifierForFactory(identifier: string): string | undefined { + return identifier === DEFAULT_ENTRY_NAME ? undefined : identifier; +} + +function isComponentEager(component: Component): boolean { + return component.instantiationMode === InstantiationMode.EAGER; +} diff --git a/packages/component/src/types.ts b/packages/component/src/types.ts new file mode 100644 index 00000000000..7d2d63a9ec1 --- /dev/null +++ b/packages/component/src/types.ts @@ -0,0 +1,62 @@ +/** + * @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 { ComponentContainer } from './component_container'; + +export const enum InstantiationMode { + LAZY = 'LAZY', // Currently all components are LAZY in JS SDK + EAGER = 'EAGER' +} + +/** + * PUBLIC: A public component provides a set of public APIs to customers. A service namespace will be patched + * onto `firebase` namespace. Assume the component name is `test`, customers will be able + * to get the service by calling `firebase.test()` or `app.test()` where `app` is a `FirebaseApp` instance. + * + * PRIVATE: A private component provides a set of private APIs that are used internally by other + * Firebase SDKs. No serivce namespace is created in `firebase` namespace and customers have no way to get them. + */ +export const enum ComponentType { + PUBLIC = 'PUBLIC', + PRIVATE = 'PRIVATE' +} + +/** + * Factory to create an instance of type T, given a ComponentContainer. + * ComponentContainer is the IOC container that provides {@link Provider} + * for dependencies. + * + * NOTE: The container only provides {@link Provider} rather than the actual instances of dependencies. + * It is useful for lazily loaded dependencies and optional dependencies. + */ +export type InstanceFactory = ( + container: ComponentContainer, + instanceIdentifier?: string +) => NameServiceMapping[T]; + +export interface Dictionary { + [key: string]: unknown; +} + +/** + * This interface will be extended by Firebase SDKs to provide service name and service type mapping. + * It is used as a generic constraint to ensure type safety. + */ +export interface NameServiceMapping {} + +export type Name = keyof NameServiceMapping; +export type Service = NameServiceMapping[Name]; diff --git a/packages/component/test/setup.ts b/packages/component/test/setup.ts new file mode 100644 index 00000000000..5f3858ba898 --- /dev/null +++ b/packages/component/test/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(async () => { + restore(); +}); diff --git a/packages/component/test/util.ts b/packages/component/test/util.ts new file mode 100644 index 00000000000..2045c1780b9 --- /dev/null +++ b/packages/component/test/util.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 { DEFAULT_ENTRY_NAME } from '../src/constants'; +import { FirebaseApp } from '@firebase/app-types'; +import { + InstanceFactory, + InstantiationMode, + ComponentType, + Name +} from '../src/types'; +import { Component } from '../src/component'; + +export function getFakeApp(appName: string = DEFAULT_ENTRY_NAME): 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 () => {} + }; +} + +export function getFakeComponent( + name: T, + factory: InstanceFactory, + multipleInstance: boolean = false, + instantiationMode = InstantiationMode.LAZY +): Component { + return new Component(name, factory, ComponentType.PUBLIC) + .setMultipleInstances(multipleInstance) + .setInstantiationMode(instantiationMode); +} diff --git a/packages/component/tsconfig.json b/packages/component/tsconfig.json new file mode 100644 index 00000000000..735ea623511 --- /dev/null +++ b/packages/component/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "outDir": "dist", + "downlevelIteration": true + }, + "exclude": [ + "dist/**/*" + ] +} \ No newline at end of file