diff --git a/integration/browserify/src/namespace.test.js b/integration/browserify/src/namespace.test.js index 321a10d3d20..17b18cd3754 100644 --- a/integration/browserify/src/namespace.test.js +++ b/integration/browserify/src/namespace.test.js @@ -25,7 +25,8 @@ firebase.initializeApp({ databaseURL: 'https://test-project-name.firebaseio.com', projectId: 'test-project-name', storageBucket: 'test-project-name.appspot.com', - messagingSenderId: '012345678910' + messagingSenderId: '012345678910', + appId: 'myAppId' }); describe('Firebase Namespace Validation', function() { diff --git a/integration/shared/namespaceDefinition.json b/integration/shared/namespaceDefinition.json index a6a85247011..3f4bb783cc9 100644 --- a/integration/shared/namespaceDefinition.json +++ b/integration/shared/namespaceDefinition.json @@ -40,7 +40,7 @@ }, "INTERNAL": { "__type": "object", - "registerService": { + "registerComponent": { "__type": "function" }, "extendNamespace": { @@ -55,20 +55,8 @@ "removeApp": { "__type": "function" }, - "factories": { - "__type": "object", - "storage": { - "__type": "function" - }, - "auth": { - "__type": "function" - }, - "database": { - "__type": "function" - }, - "messaging": { - "__type": "function" - } + "components": { + "__type": "Map" }, "ErrorFactory": { "__type": "function" diff --git a/integration/typescript/test/namespace.test.ts b/integration/typescript/test/namespace.test.ts index 5a73a1b88ce..21f9962209d 100644 --- a/integration/typescript/test/namespace.test.ts +++ b/integration/typescript/test/namespace.test.ts @@ -25,7 +25,8 @@ firebase.initializeApp({ databaseURL: 'https://test-project-name.firebaseio.com', projectId: 'test-project-name', storageBucket: 'test-project-name.appspot.com', - messagingSenderId: '012345678910' + messagingSenderId: '012345678910', + appId: 'myAppId' }); describe('Firebase Namespace Validation', function() { diff --git a/integration/webpack/src/namespace.test.js b/integration/webpack/src/namespace.test.js index 321a10d3d20..17b18cd3754 100644 --- a/integration/webpack/src/namespace.test.js +++ b/integration/webpack/src/namespace.test.js @@ -25,7 +25,8 @@ firebase.initializeApp({ databaseURL: 'https://test-project-name.firebaseio.com', projectId: 'test-project-name', storageBucket: 'test-project-name.appspot.com', - messagingSenderId: '012345678910' + messagingSenderId: '012345678910', + appId: 'myAppId' }); describe('Firebase Namespace Validation', function() { 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..95633a7046f --- /dev/null +++ b/packages/analytics-interop-types/index.d.ts @@ -0,0 +1,48 @@ +/** + * @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; +} + +export type FirebaseAnalyticsInternalName = 'analytics-internal'; + +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..196dbb5f8a6 --- /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.7.2" + } +} 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 4e0f0ea7d5d..32ae96d6324 100644 --- a/packages/analytics/index.test.ts +++ b/packages/analytics/index.test.ts @@ -24,7 +24,10 @@ import { factory as analyticsFactory, resetGlobalVars } from './index'; -import { getFakeApp } from './testing/get-fake-app'; +import { + getFakeApp, + getFakeInstallations +} from './testing/get-fake-firebase-services'; import { FirebaseApp } from '@firebase/app-types'; import { GtagCommand, EventName } from './src/constants'; import { findGtagScriptOnPage } from './src/helpers'; @@ -39,21 +42,29 @@ 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'); + const installations = getFakeInstallations(); + expect(() => analyticsFactory(app, installations)).to.throw( + 'field is empty' + ); }); it('Throws if creating an instance with already-used analytics ID', () => { const app = getFakeApp(analyticsId); + const installations = getFakeInstallations(); resetGlobalVars(false, { [analyticsId]: Promise.resolve() }); - expect(() => analyticsFactory(app, () => {})).to.throw('already exists'); + expect(() => analyticsFactory(app, installations)).to.throw( + 'already exists' + ); }); describe('Standard app, page already has user gtag script', () => { let app: FirebaseApp = {} as FirebaseApp; before(() => { resetGlobalVars(); app = getFakeApp(analyticsId); + const installations = getFakeInstallations(); + window['gtag'] = gtagStub; window['dataLayer'] = []; - analyticsInstance = analyticsFactory(app, () => {}); + analyticsInstance = analyticsFactory(app, installations); }); after(() => { delete window['gtag']; @@ -113,13 +124,14 @@ describe('FirebaseAnalytics instance tests', () => { before(() => { resetGlobalVars(); const app = getFakeApp(analyticsId); + const installations = getFakeInstallations(); window[customGtagName] = gtagStub; window[customDataLayerName] = []; analyticsSettings({ dataLayerName: customDataLayerName, gtagName: customGtagName }); - analyticsInstance = analyticsFactory(app, () => {}); + analyticsInstance = analyticsFactory(app, installations); }); after(() => { delete window[customGtagName]; @@ -162,7 +174,8 @@ describe('FirebaseAnalytics instance tests', () => { before(() => { resetGlobalVars(); const app = getFakeApp(analyticsId); - analyticsInstance = analyticsFactory(app, () => {}); + const installations = getFakeInstallations(); + analyticsInstance = analyticsFactory(app, installations); }); after(() => { delete window['gtag']; diff --git a/packages/analytics/index.ts b/packages/analytics/index.ts index fbb7c645283..f99c16d93c4 100644 --- a/packages/analytics/index.ts +++ b/packages/analytics/index.ts @@ -15,13 +15,18 @@ * limitations under the License. */ import firebase from '@firebase/app'; +import '@firebase/installations'; 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, + ComponentContainer +} from '@firebase/component'; +import { ERROR_FACTORY, AnalyticsError } from './src/errors'; declare global { interface Window { @@ -34,18 +39,43 @@ 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(); + const installations = container + .getProvider('installations') + .getImmediate(); + + return factory(app, installations); + }, + 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', internalFactory, ComponentType.PRIVATE) + ); + + function internalFactory( + container: ComponentContainer + ): FirebaseAnalyticsInternal { + 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 + }); + } + } } export { factory, settings, resetGlobalVars }; diff --git a/packages/analytics/package.json b/packages/analytics/package.json index 4ef27a1877b..c694bb00585 100644 --- a/packages/analytics/package.json +++ b/packages/analytics/package.json @@ -26,6 +26,7 @@ "@firebase/analytics-types": "0.2.3", "@firebase/installations": "0.3.6", "@firebase/util": "0.2.34", + "@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 6c7ec41c7c1..b7fffe5a7db 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, @@ -28,7 +27,6 @@ import { setUserProperties, setAnalyticsCollectionEnabled } from './functions'; -import '@firebase/installations'; import { initializeGAId, insertScriptTag, @@ -38,6 +36,8 @@ import { } from './helpers'; import { ANALYTICS_ID_FIELD } from './constants'; import { AnalyticsError, ERROR_FACTORY } from './errors'; +import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseInstallations } from '@firebase/installations-types'; /** * Maps gaId to FID fetch promises. @@ -104,8 +104,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 + installations: FirebaseInstallations ): FirebaseAnalytics { const analyticsId = app.options[ANALYTICS_ID_FIELD]; if (!analyticsId) { @@ -139,7 +138,11 @@ export function factory( globalInitDone = true; } // Async but non-blocking. - initializedIdPromisesMap[analyticsId] = initializeGAId(app, gtagCoreFunction); + initializedIdPromisesMap[analyticsId] = initializeGAId( + app, + installations, + gtagCoreFunction + ); const analyticsInstance: FirebaseAnalytics = { app, @@ -161,13 +164,5 @@ export function factory( setAnalyticsCollectionEnabled(analyticsId, enabled) }; - extendApp({ - INTERNAL: { - analytics: { - logEvent: analyticsInstance.logEvent - } - } - }); - return analyticsInstance; } diff --git a/packages/analytics/src/helpers.test.ts b/packages/analytics/src/helpers.test.ts index 9ded80d9a0a..50a9f77d431 100644 --- a/packages/analytics/src/helpers.test.ts +++ b/packages/analytics/src/helpers.test.ts @@ -26,7 +26,10 @@ import { wrapOrCreateGtag, findGtagScriptOnPage } from './helpers'; -import { getFakeApp } from '../testing/get-fake-app'; +import { + getFakeApp, + getFakeInstallations +} from '../testing/get-fake-firebase-services'; import { GtagCommand } from './constants'; import { Deferred } from '@firebase/util'; @@ -36,8 +39,9 @@ const mockFid = 'fid-1234-zyxw'; describe('FirebaseAnalytics methods', () => { it('initializeGAId gets FID from installations and calls gtag config with it', async () => { const gtagStub: SinonStub = stub(); - const app = getFakeApp(mockAnalyticsId, mockFid); - await initializeGAId(app, gtagStub); + const app = getFakeApp(mockAnalyticsId); + const installations = getFakeInstallations(mockFid); + await initializeGAId(app, installations, gtagStub); expect(gtagStub).to.be.calledWith(GtagCommand.CONFIG, mockAnalyticsId, { 'firebase_id': mockFid, 'origin': 'firebase', diff --git a/packages/analytics/src/helpers.ts b/packages/analytics/src/helpers.ts index eb7fb429adc..e0583611434 100644 --- a/packages/analytics/src/helpers.ts +++ b/packages/analytics/src/helpers.ts @@ -30,7 +30,7 @@ import { ORIGIN_KEY, GTAG_URL } from './constants'; -import '@firebase/installations'; +import { FirebaseInstallations } from '@firebase/installations-types'; /** * Initialize the analytics instance in gtag.js by calling config command with fid. @@ -42,9 +42,10 @@ import '@firebase/installations'; */ export async function initializeGAId( app: FirebaseApp, + installations: FirebaseInstallations, gtagCore: Gtag ): Promise { - const fid = await app.installations().getId(); + const fid = await installations.getId(); // This command initializes gtag.js and only needs to be called once for the entire web app, // but since it is idempotent, we can call it multiple times. diff --git a/packages/analytics/testing/get-fake-app.ts b/packages/analytics/testing/get-fake-firebase-services.ts similarity index 73% rename from packages/analytics/testing/get-fake-app.ts rename to packages/analytics/testing/get-fake-firebase-services.ts index 44d1a129ded..a4ef9b35047 100644 --- a/packages/analytics/testing/get-fake-app.ts +++ b/packages/analytics/testing/get-fake-firebase-services.ts @@ -16,12 +16,9 @@ */ import { FirebaseApp } from '@firebase/app-types'; -import { stub } from 'sinon'; +import { FirebaseInstallations } from '@firebase/installations-types'; -export function getFakeApp( - measurementId?: string, - fid: string = 'fid-1234' -): FirebaseApp { +export function getFakeApp(measurementId?: string): FirebaseApp { return { name: 'appName', options: { @@ -36,8 +33,19 @@ export function getFakeApp( }, automaticDataCollectionEnabled: true, delete: async () => {}, - installations: stub().returns({ getId: () => Promise.resolve(fid) }), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + installations: null as any, // eslint-disable-next-line @typescript-eslint/no-explicit-any analytics: null as any }; } + +export function getFakeInstallations( + fid: string = 'fid-1234' +): FirebaseInstallations { + return { + getId: async () => fid, + getToken: async () => 'authToken', + delete: async () => undefined + }; +} 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..7badc764fee 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, ComponentContainer } from '@firebase/component'; export interface FirebaseServiceInternals { /** @@ -80,8 +82,10 @@ export interface FirebaseAppInternals { } export interface _FirebaseApp extends FirebaseApp { - INTERNAL: FirebaseAppInternals; - _removeServiceInstance: (name: string, instanceIdentifier?: string) => void; + container: ComponentContainer; + _addComponent(component: Component): void; + _addOrOverwriteComponent(component: Component): void; + _removeServiceInstance(name: string, instanceIdentifier?: string): void; } export interface _FirebaseNamespace extends FirebaseNamespace { INTERNAL: { @@ -99,13 +103,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 +139,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 4378efb4e56..37ec6e00256 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.8", "@firebase/util": "0.2.34", "@firebase/logger": "0.1.31", + "@firebase/component": "0.1.0", "tslib": "1.10.0", "dom-storage": "2.1.0", "xmlhttprequest": "1.8.0" 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/auth-interop-types/README.md b/packages/auth-interop-types/README.md new file mode 100644 index 00000000000..67ed6389e04 --- /dev/null +++ b/packages/auth-interop-types/README.md @@ -0,0 +1,3 @@ +# @firebase/auth-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/auth-interop-types/index.d.ts b/packages/auth-interop-types/index.d.ts new file mode 100644 index 00000000000..f529a240e52 --- /dev/null +++ b/packages/auth-interop-types/index.d.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface FirebaseAuthTokenData { + accessToken: string; +} + +export interface FirebaseAuthInternal { + getToken(refreshToken?: boolean): Promise; + getUid(): string | null; + addAuthTokenListener(fn: (token: string | null) => void): void; + removeAuthTokenListener(fn: (token: string | null) => void): void; +} + +export type FirebaseAuthInternalName = 'auth-internal'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'auth-internal': FirebaseAuthInternal; + } +} diff --git a/packages/auth-interop-types/package.json b/packages/auth-interop-types/package.json new file mode 100644 index 00000000000..de16e938b0f --- /dev/null +++ b/packages/auth-interop-types/package.json @@ -0,0 +1,28 @@ +{ + "name": "@firebase/auth-interop-types", + "version": "0.1.0", + "description": "@firebase/auth interop Types", + "author": "Firebase (https://firebase.google.com/)", + "license": "Apache-2.0", + "scripts": { + "test": "tsc" + }, + "files": [ + "index.d.ts" + ], + "peerDependencies": { + "@firebase/app-types": "0.x", + "@firebase/util": "0.x" + }, + "repository": { + "directory": "packages/auth-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.7.2" + } +} diff --git a/packages/auth-interop-types/tsconfig.json b/packages/auth-interop-types/tsconfig.json new file mode 100644 index 00000000000..9a785433d90 --- /dev/null +++ b/packages/auth-interop-types/tsconfig.json @@ -0,0 +1,9 @@ +{ + "extends": "../../config/tsconfig.base.json", + "compilerOptions": { + "noEmit": true + }, + "exclude": [ + "dist/**/*" + ] +} diff --git a/packages/auth-types/index.d.ts b/packages/auth-types/index.d.ts index 5467c2300f7..61039a1b32b 100644 --- a/packages/auth-types/index.d.ts +++ b/packages/auth-types/index.d.ts @@ -395,3 +395,9 @@ declare module '@firebase/app-types' { auth?(): FirebaseAuth; } } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'auth': FirebaseAuth; + } +} diff --git a/packages/auth/src/exports_auth.js b/packages/auth/src/exports_auth.js index 97f5b6b1ae4..7b6ed725095 100644 --- a/packages/auth/src/exports_auth.js +++ b/packages/auth/src/exports_auth.js @@ -618,27 +618,10 @@ fireauth.exportlib.exportFunction( (function() { if (typeof firebase === 'undefined' || !firebase.INTERNAL || - !firebase.INTERNAL.registerService) { + !firebase.INTERNAL.registerComponent) { throw new Error('Cannot find the firebase namespace; be sure to include ' + 'firebase-app.js before this library.'); } else { - /** @type {!firebase.ServiceFactory} */ - var factory = function(app, extendApp) { - var auth = new fireauth.Auth(app); - extendApp({ - 'INTERNAL': { - // Extend app.INTERNAL.getUid. - 'getUid': goog.bind(auth.getUid, auth), - 'getToken': goog.bind(auth.getIdTokenInternal, auth), - 'addAuthTokenListener': - goog.bind(auth.addAuthTokenListenerInternal, auth), - 'removeAuthTokenListener': - goog.bind(auth.removeAuthTokenListenerInternal, auth) - } - }); - return auth; - }; - var namespace = { // Exports firebase.auth.ActionCodeInfo.Operation. 'ActionCodeInfo': { @@ -687,34 +670,45 @@ fireauth.exportlib.exportFunction( fireauth.exportlib.exportFunction(namespace, 'ActionCodeURL', fireauth.ActionCodeURL, []); - // Register Auth service with firebase.App. - firebase.INTERNAL.registerService( - fireauth.exportlib.AUTH_TYPE, - factory, - namespace, - // Initialize Auth when an App is created, so that tokens and Auth state - // listeners are available. - function (event, app) { - if (event === 'create') { - try { - app[fireauth.exportlib.AUTH_TYPE](); - } catch (e) { - // This is a silent operation in the background. If the auth - // initialization fails, it should not cause a fatal error. - // Instead when the developer tries to initialize again manually, - // the error will be thrown. - // One specific use case here is the initialization for the nodejs - // client when no API key is provided. This is commonly used - // for unauthenticated database access. - } - } - } - ); - + // Create auth components to register with firebase. + // Provides Auth public APIs. + const authComponent = { + 'name': fireauth.exportlib.AUTH_TYPE, + 'instanceFactory': function(container) { + var app = container['getProvider']('app')['getImmediate'](); + return new fireauth.Auth(app); + }, + 'multipleInstances': false, + 'serviceProps': namespace, + 'instantiationMode': 'LAZY', + 'type': 'PUBLIC' + }; + + // Provides Auth internal APIs. + const authInteropComponent = { + 'name': 'auth-internal', + 'instanceFactory': function(container) { + var auth = container['getProvider'](fireauth.exportlib.AUTH_TYPE)['getImmediate'](); + return { + 'getUid': goog.bind(auth.getUid, auth), + 'getToken': goog.bind(auth.getIdTokenInternal, auth), + 'addAuthTokenListener': + goog.bind(auth.addAuthTokenListenerInternal, auth), + 'removeAuthTokenListener': + goog.bind(auth.removeAuthTokenListenerInternal, auth) + }; + }, + 'multipleInstances': false, + 'instantiationMode': 'LAZY', + 'type': 'PRIVATE' + }; + + firebase.INTERNAL.registerComponent(authComponent); + firebase.INTERNAL.registerComponent(authInteropComponent); // Expose User as firebase.User. firebase.INTERNAL.extendNamespace({ 'User': fireauth.AuthUser }); } -})(); +})(); \ No newline at end of file diff --git a/packages/auth/test/auth_test.js b/packages/auth/test/auth_test.js index b8d6709a475..66785bef6e1 100644 --- a/packages/auth/test/auth_test.js +++ b/packages/auth/test/auth_test.js @@ -73,6 +73,8 @@ var appId1 = 'appId1'; var appId2 = 'appId2'; var auth1 = null; var auth2 = null; +var authInternal1 = null; +var authInternal2 = null; var app1 = null; var app2 = null; var authUi1 = null; @@ -1014,9 +1016,10 @@ function testGetUid_userSignedIn() { // Initialize App and Auth. app1 = firebase.initializeApp(config1, appId1); auth1 = app1.auth(); + authInternal1 = app1.container.getProvider('auth-internal').getImmediate(); // Initially getUid() should return null; assertNull(auth1.getUid()); - assertNull(app1.INTERNAL.getUid()); + assertNull(authInternal1.getUid()); // Listen to Auth changes. var unsubscribe = auth1.onIdTokenChanged(function(currentUser) { // Unsubscribe of Auth state change listener. @@ -1024,7 +1027,7 @@ function testGetUid_userSignedIn() { // Logged in test user should be detected. // Confirm getUid() returns expected UID. assertEquals(accountInfo1['uid'], auth1.getUid()); - assertEquals(accountInfo1['uid'], app1.INTERNAL.getUid()); + assertEquals(accountInfo1['uid'], authInternal1.getUid()); goog.Timer.promise(10).then(function() { // Sign out. return auth1.signOut(); @@ -1033,7 +1036,7 @@ function testGetUid_userSignedIn() { }).then(function() { // getUid() should return null. assertNull(auth1.getUid()); - assertNull(app1.INTERNAL.getUid()); + assertNull(authInternal1.getUid()); asyncTestCase.signal(); }); }); @@ -1065,19 +1068,20 @@ function testGetUid_noUserSignedIn() { // Initialize App and Auth. app1 = firebase.initializeApp(config1, appId1); auth1 = app1.auth(); + authInternal1 = app1.container.getProvider('auth-internal').getImmediate(); // Listen to Auth changes. var unsubscribe = auth1.onIdTokenChanged(function(currentUser) { // Unsubscribe of Auth state change listener. unsubscribe(); // Initially getUid() should return null; assertNull(auth1.getUid()); - assertNull(app1.INTERNAL.getUid()); + assertNull(authInternal1.getUid()); // Sign in with email and password. auth1.signInWithEmailAndPassword('user@example.com', 'password') .then(function(userCredential) { // getUid() should return the test user UID. assertEquals(accountInfo1['uid'], auth1.getUid()); - assertEquals(accountInfo1['uid'], app1.INTERNAL.getUid()); + assertEquals(accountInfo1['uid'], authInternal1.getUid()); asyncTestCase.signal(); }); }); @@ -1118,11 +1122,13 @@ function testNotifyAuthListeners() { config1['apiKey'] + ':' + appId1); currentUserStorageManager.setCurrentUser(user).then(function() { app1 = firebase.initializeApp(config1, appId1); - app1.INTERNAL.addAuthTokenListener(app1AuthTokenListener); auth1 = app1.auth(); + authInternal1 = app1.container.getProvider('auth-internal').getImmediate(); + authInternal1.addAuthTokenListener(app1AuthTokenListener); app2 = firebase.initializeApp(config2, appId2); - app2.INTERNAL.addAuthTokenListener(app2AuthTokenListener); auth2 = app2.auth(); + authInternal2 = app2.container.getProvider('auth-internal').getImmediate(); + authInternal2.addAuthTokenListener(app2AuthTokenListener); // Confirm all listeners reset. assertEquals(0, listener1.getCallCount()); assertEquals(0, listener2.getCallCount()); @@ -1170,7 +1176,7 @@ function testNotifyAuthListeners() { app1AuthTokenListener.reset(); listener2.reset(); auth1.removeAuthTokenListener(listener2); - app1.INTERNAL.removeAuthTokenListener(app1AuthTokenListener); + authInternal1.removeAuthTokenListener(app1AuthTokenListener); // Force token change. currentAccessToken = 'accessToken3'; auth1['currentUser'].getIdToken().then(function(token) { @@ -10077,7 +10083,7 @@ function testAuth_proactiveTokenRefresh_multipleUsers() { auth1 = app1.auth(); var subscriber = function(token) {}; // Simulate Firebase service added. - app1.INTERNAL.addAuthTokenListener(subscriber); + app1.container.getProvider('auth-internal').getImmediate().addAuthTokenListener(subscriber); // Simulate user1 signed in. auth1.signInWithIdTokenResponse(expectedTokenResponse).then(function() { // Current user should be set to user1. @@ -10155,7 +10161,7 @@ function testAuth_proactiveTokenRefresh_firebaseServiceAddedAfterSignIn() { assertEquals( 0, fireauth.AuthUser.prototype.startProactiveRefresh.getCallCount()); // Simulate Firebase service added. - app1.INTERNAL.addAuthTokenListener(subscriber); + app1.container.getProvider('auth-internal').getImmediate().addAuthTokenListener(subscriber); // Confirm proactive refresh started on that user. assertEquals( 1, fireauth.AuthUser.prototype.startProactiveRefresh.getCallCount()); @@ -10199,10 +10205,11 @@ function testAuth_proactiveTokenRefresh_firebaseServiceRemovedAfterSignIn() { auth1 = app1.auth(); var subscriber = function(token) {}; // Simulate Firebase service added. - app1.INTERNAL.addAuthTokenListener(subscriber); + authInternal1 = app1.container.getProvider('auth-internal').getImmediate(); + authInternal1.addAuthTokenListener(subscriber); // Add same listener again to check that removing it once will ensure the // proactive refresh is stopped. - app1.INTERNAL.addAuthTokenListener(subscriber); + authInternal1.addAuthTokenListener(subscriber); // Simulate user signed in. auth1.signInWithIdTokenResponse(expectedTokenResponse).then(function() { // Current user should be set to user1. @@ -10214,7 +10221,7 @@ function testAuth_proactiveTokenRefresh_firebaseServiceRemovedAfterSignIn() { assertEquals( 0, fireauth.AuthUser.prototype.stopProactiveRefresh.getCallCount()); // Simulate Firebase service removed. - app1.INTERNAL.removeAuthTokenListener(subscriber); + authInternal1.removeAuthTokenListener(subscriber); // Confirm proactive refresh stopped on that user. assertEquals( 1, fireauth.AuthUser.prototype.stopProactiveRefresh.getCallCount()); diff --git a/packages/component/.eslintrc.js b/packages/component/.eslintrc.js new file mode 100644 index 00000000000..407df78bdb9 --- /dev/null +++ b/packages/component/.eslintrc.js @@ -0,0 +1,6 @@ +module.exports = { + extends: '../../config/.eslintrc.js', + parserOptions: { + project: 'tsconfig.json' + } +}; 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..b680dfb5c81 --- /dev/null +++ b/packages/component/package.json @@ -0,0 +1,49 @@ +{ + "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.js '**/*.ts' --ignore-path '../../.gitignore'", + "lint:fix": "eslint --fix -c .eslintrc.js '**/*.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.34", + "tslib": "1.10.0" + }, + "license": "Apache-2.0", + "devDependencies": { + "rollup": "1.27.2", + "rollup-plugin-typescript2": "0.25.2", + "typescript": "3.7.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..d4eed68a241 --- /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 + // a 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 a 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 + // a 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 diff --git a/packages/database-types/index.d.ts b/packages/database-types/index.d.ts index 9b79970c1ea..4a9ccc2d5b9 100644 --- a/packages/database-types/index.d.ts +++ b/packages/database-types/index.d.ts @@ -99,7 +99,7 @@ export interface Reference extends Query { key: string | null; onDisconnect(): OnDisconnect; parent: Reference | null; - push(value?: any, onComplete?: (a: Error | null) => any): ThenableReference; + push(value?: any, onComplete?: (a: Error | null) => any): Reference; remove(onComplete?: (a: Error | null) => any): Promise; root: Reference; set(value: any, onComplete?: (a: Error | null) => any): Promise; @@ -132,3 +132,9 @@ export function enableLogging( logger?: boolean | ((a: string) => any), persistent?: boolean ): any; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'database': FirebaseDatabase; + } +} diff --git a/packages/database/index.node.ts b/packages/database/index.node.ts index 50337ae756b..784b959bf00 100644 --- a/packages/database/index.node.ts +++ b/packages/database/index.node.ts @@ -16,7 +16,7 @@ */ import { FirebaseNamespace, FirebaseApp } from '@firebase/app-types'; -import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { _FirebaseNamespace, _FirebaseApp } from '@firebase/app-types/private'; import { Database } from './src/api/Database'; import { DataSnapshot } from './src/api/DataSnapshot'; import { Query } from './src/api/Query'; @@ -30,6 +30,13 @@ import { setSDKVersion } from './src/core/version'; import { CONSTANTS, isNodeSdk } from '@firebase/util'; import { setWebSocketImpl } from './src/realtime/WebSocketConnection'; import { Client } from 'faye-websocket'; +import { + Component, + ComponentType, + Provider, + ComponentContainer +} from '@firebase/component'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; setWebSocketImpl(Client); @@ -51,8 +58,28 @@ export function initStandalone(app: FirebaseApp, url: string, version: string) { CONSTANTS.NODE_ADMIN = true; setSDKVersion(version); + /** + * Create a 'auth-internal' component using firebase-admin-node's implementation + * that implements FirebaseAuthInternal. + * ComponentContainer('database-admin') is just a placeholder that doesn't perform + * any actual function. + */ + const authProvider = new Provider( + 'auth-internal', + new ComponentContainer('database-admin') + ); + authProvider.setComponent( + new Component( + 'auth-internal', + // firebase-admin-node's app.INTERNAL implements FirebaseAuthInternal interface + // eslint-disable-next-line @eslint-tslint/no-explicit-any + () => (app as any).INTERNAL, + ComponentType.PRIVATE + ) + ); + return { - instance: RepoManager.getInstance().databaseFromApp(app, url), + instance: RepoManager.getInstance().databaseFromApp(app, authProvider, url), namespace: { Reference, Query, @@ -71,22 +98,37 @@ export function registerDatabase(instance: FirebaseNamespace) { setSDKVersion(instance.SDK_VERSION); // Register the Database Service with the 'firebase' namespace. - const namespace = (instance as _FirebaseNamespace).INTERNAL.registerService( - 'database', - (app, unused, url) => RepoManager.getInstance().databaseFromApp(app, url), - // firebase.database namespace properties - { - Reference, - Query, - Database, - DataSnapshot, - enableLogging, - INTERNAL, - ServerValue, - TEST_ACCESS - }, - null, - true + const namespace = (instance as _FirebaseNamespace).INTERNAL.registerComponent( + new Component( + 'database', + (container, url) => { + /* Dependencies */ + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + const authProvider = container.getProvider('auth-internal'); + + return RepoManager.getInstance().databaseFromApp( + app, + authProvider, + url + ); + }, + ComponentType.PUBLIC + ) + .setServiceProps( + // firebase.database namespace properties + { + Reference, + Query, + Database, + DataSnapshot, + enableLogging, + INTERNAL, + ServerValue, + TEST_ACCESS + } + ) + .setMultipleInstances(true) ); if (isNodeSdk()) { diff --git a/packages/database/index.ts b/packages/database/index.ts index 00a3848447c..4b18d8f4fa2 100644 --- a/packages/database/index.ts +++ b/packages/database/index.ts @@ -29,6 +29,7 @@ import * as TEST_ACCESS from './src/api/test_access'; import { isNodeSdk } from '@firebase/util'; import * as types from '@firebase/database-types'; import { setSDKVersion } from './src/core/version'; +import { Component, ComponentType } from '@firebase/component'; const ServerValue = Database.ServerValue; @@ -37,22 +38,37 @@ export function registerDatabase(instance: FirebaseNamespace) { setSDKVersion(instance.SDK_VERSION); // Register the Database Service with the 'firebase' namespace. - const namespace = (instance as _FirebaseNamespace).INTERNAL.registerService( - 'database', - (app, unused, url) => RepoManager.getInstance().databaseFromApp(app, url), - // firebase.database namespace properties - { - Reference, - Query, - Database, - DataSnapshot, - enableLogging, - INTERNAL, - ServerValue, - TEST_ACCESS - }, - null, - true + const namespace = (instance as _FirebaseNamespace).INTERNAL.registerComponent( + new Component( + 'database', + (container, url) => { + /* Dependencies */ + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + const authProvider = container.getProvider('auth-internal'); + + return RepoManager.getInstance().databaseFromApp( + app, + authProvider, + url + ); + }, + ComponentType.PUBLIC + ) + .setServiceProps( + // firebase.database namespace properties + { + Reference, + Query, + Database, + DataSnapshot, + enableLogging, + INTERNAL, + ServerValue, + TEST_ACCESS + } + ) + .setMultipleInstances(true) ); if (isNodeSdk()) { diff --git a/packages/database/package.json b/packages/database/package.json index 63f357476c9..0651ca54433 100644 --- a/packages/database/package.json +++ b/packages/database/package.json @@ -16,7 +16,7 @@ "test": "yarn test:emulator", "test:all": "run-p test:browser test:node", "test:browser": "karma start --single-run", - "test:node": "TS_NODE_CACHE=NO TS_NODE_COMPILER_OPTIONS='{\"module\":\"commonjs\"}' nyc --reporter lcovonly -- mocha 'test/{,!(browser)/**/}*.test.ts' --file index.node.ts --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/{,!(browser)/**/}*.test.ts' --file index.node.ts --opts ../../config/mocha.node.opts", "test:emulator": "ts-node --compiler-options='{\"module\":\"commonjs\"}' ../../scripts/emulator-testing/database-test-runner.ts", "prepare": "yarn build" }, diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 8163cfb7c84..c42462ae0dc 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -26,12 +26,13 @@ import { validateUrl } from '../core/util/validation'; import { FirebaseApp } from '@firebase/app-types'; import { FirebaseService } from '@firebase/app-types/private'; import { RepoInfo } from '../core/RepoInfo'; +import { FirebaseDatabase } from '@firebase/database-types'; /** * Class representing a firebase database. * @implements {FirebaseService} */ -export class Database implements FirebaseService { +export class Database implements FirebaseService, FirebaseDatabase { INTERNAL: DatabaseInternals; private root_: Reference; diff --git a/packages/database/src/api/Reference.ts b/packages/database/src/api/Reference.ts index 6e110bad74c..3a1c83405f5 100644 --- a/packages/database/src/api/Reference.ts +++ b/packages/database/src/api/Reference.ts @@ -37,12 +37,13 @@ import { Deferred } from '@firebase/util'; import { SyncPoint } from '../core/SyncPoint'; import { Database } from './Database'; import { DataSnapshot } from './DataSnapshot'; +import * as types from '@firebase/database-types'; export interface ReferenceConstructor { new (repo: Repo, path: Path): Reference; } -export class Reference extends Query { +export class Reference extends Query implements types.Reference { public then: (a?: any) => Promise; public catch: (a?: Error) => Promise; diff --git a/packages/database/src/core/AuthTokenProvider.ts b/packages/database/src/core/AuthTokenProvider.ts index b8973d31c84..8a05438dbfb 100644 --- a/packages/database/src/core/AuthTokenProvider.ts +++ b/packages/database/src/core/AuthTokenProvider.ts @@ -15,48 +15,68 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { FirebaseAuthTokenData } from '@firebase/app-types/private'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; import { log, warn } from './util/util'; +import { FirebaseApp } from '@firebase/app-types'; /** * Abstraction around FirebaseApp's token fetching capabilities. */ export class AuthTokenProvider { - /** - * @param {!FirebaseApp} app_ - */ - constructor(private app_: FirebaseApp) {} + private auth_: FirebaseAuthInternal | null = null; + constructor( + private app_: FirebaseApp, + private authProvider_: Provider + ) { + this.auth_ = authProvider_.getImmediate({ optional: true }); + if (!this.auth_) { + authProvider_.get().then(auth => (this.auth_ = auth)); + } + } /** * @param {boolean} forceRefresh * @return {!Promise} */ getToken(forceRefresh: boolean): Promise { - return this.app_['INTERNAL']['getToken'](forceRefresh).then( - null, - // .catch - function(error) { - // TODO: Need to figure out all the cases this is raised and whether - // this makes sense. - if (error && error.code === 'auth/token-not-initialized') { - log('Got auth/token-not-initialized error. Treating as null token.'); - return null; - } else { - return Promise.reject(error); - } + if (!this.auth_) { + return Promise.resolve(null); + } + + return this.auth_.getToken(forceRefresh).catch(function(error) { + // TODO: Need to figure out all the cases this is raised and whether + // this makes sense. + if (error && error.code === 'auth/token-not-initialized') { + log('Got auth/token-not-initialized error. Treating as null token.'); + return null; + } else { + return Promise.reject(error); } - ); + }); } addTokenChangeListener(listener: (token: string | null) => void) { // TODO: We might want to wrap the listener and call it with no args to // avoid a leaky abstraction, but that makes removing the listener harder. - this.app_['INTERNAL']['addAuthTokenListener'](listener); + if (this.auth_) { + this.auth_.addAuthTokenListener(listener); + } else { + setTimeout(() => listener(null), 0); + this.authProvider_ + .get() + .then(auth => auth.addAuthTokenListener(listener)); + } } removeTokenChangeListener(listener: (token: string | null) => void) { - this.app_['INTERNAL']['removeAuthTokenListener'](listener); + this.authProvider_ + .get() + .then(auth => auth.removeAuthTokenListener(listener)); } notifyForInvalidToken() { diff --git a/packages/database/src/core/Repo.ts b/packages/database/src/core/Repo.ts index cc33a526055..dcabc055e66 100644 --- a/packages/database/src/core/Repo.ts +++ b/packages/database/src/core/Repo.ts @@ -44,6 +44,8 @@ import { EventRegistration } from './view/EventRegistration'; import { StatsCollection } from './stats/StatsCollection'; import { Event } from './view/Event'; import { Node } from './snap/Node'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; const INTERRUPT_REASON = 'repo_interrupt'; @@ -79,9 +81,10 @@ export class Repo { constructor( public repoInfo_: RepoInfo, forceRestClient: boolean, - public app: FirebaseApp + public app: FirebaseApp, + authProvider: Provider ) { - const authTokenProvider = new AuthTokenProvider(app); + const authTokenProvider = new AuthTokenProvider(app, authProvider); this.stats_ = StatsManager.getCollection(repoInfo_); diff --git a/packages/database/src/core/RepoManager.ts b/packages/database/src/core/RepoManager.ts index 7dee63393c7..16fd7b10091 100644 --- a/packages/database/src/core/RepoManager.ts +++ b/packages/database/src/core/RepoManager.ts @@ -24,6 +24,8 @@ import { validateUrl } from './util/validation'; import './Repo_transaction'; import { Database } from '../api/Database'; import { RepoInfo } from './RepoInfo'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; /** @const {string} */ const DATABASE_URL_OPTION = 'databaseURL'; @@ -89,7 +91,11 @@ export class RepoManager { * @param {!FirebaseApp} app * @return {!Database} */ - databaseFromApp(app: FirebaseApp, url?: string): Database { + databaseFromApp( + app: FirebaseApp, + authProvider: Provider, + url?: string + ): Database { let dbUrl: string | undefined = url || app.options[DATABASE_URL_OPTION]; if (dbUrl === undefined) { fatal( @@ -120,7 +126,7 @@ export class RepoManager { ); } - const repo = this.createRepo(repoInfo, app); + const repo = this.createRepo(repoInfo, app, authProvider); return repo.database; } @@ -150,7 +156,11 @@ export class RepoManager { * @param {!FirebaseApp} app * @return {!Repo} The Repo object for the specified server / repoName. */ - createRepo(repoInfo: RepoInfo, app: FirebaseApp): Repo { + createRepo( + repoInfo: RepoInfo, + app: FirebaseApp, + authProvider: Provider + ): Repo { let appRepos = safeGet(this.repos_, app.name); if (!appRepos) { @@ -164,7 +174,7 @@ export class RepoManager { 'Database initialized multiple times. Please make sure the format of the database URL matches with each database() call.' ); } - repo = new Repo(repoInfo, this.useRestClient_, app); + repo = new Repo(repoInfo, this.useRestClient_, app, authProvider); appRepos[repoInfo.toURLString()] = repo; return repo; diff --git a/packages/database/test/browser/crawler_support.test.ts b/packages/database/test/browser/crawler_support.test.ts index 8c2c8862dac..4d97ba1a968 100644 --- a/packages/database/test/browser/crawler_support.test.ts +++ b/packages/database/test/browser/crawler_support.test.ts @@ -18,18 +18,13 @@ import { expect } from 'chai'; import { forceRestClient } from '../../src/api/test_access'; -import { - getRandomNode, - testAuthTokenProvider, - getFreshRepoFromReference -} from '../helpers/util'; +import { getRandomNode, getFreshRepoFromReference } from '../helpers/util'; // Some sanity checks for the ReadonlyRestClient crawler support. describe('Crawler Support', function() { let initialData; let normalRef; let restRef; - let tokenProvider; beforeEach(function(done) { normalRef = getRandomNode(); @@ -38,15 +33,9 @@ describe('Crawler Support', function() { restRef = getFreshRepoFromReference(normalRef); forceRestClient(false); - tokenProvider = testAuthTokenProvider(restRef.database.app); - setInitialData(done); }); - afterEach(function() { - tokenProvider.setToken(null); - }); - function setInitialData(done) { // Set some initial data. initialData = { diff --git a/packages/database/test/helpers/util.ts b/packages/database/test/helpers/util.ts index b9016814ea4..e1eb73587ef 100644 --- a/packages/database/test/helpers/util.ts +++ b/packages/database/test/helpers/util.ts @@ -22,6 +22,8 @@ import '../../index'; import { Reference } from '../../src/api/Reference'; import { Query } from '../../src/api/Query'; import { ConnectionTarget } from '../../src/api/test_access'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { Component, ComponentType } from '@firebase/component'; export const TEST_PROJECT = require('../../../../config/project.json'); @@ -50,30 +52,22 @@ console.log(`USE_EMULATOR: ${USE_EMULATOR}. DATABASE_URL: ${DATABASE_URL}.`); let numDatabases = 0; -/** - * Fake Firebase App Authentication functions for testing. - * @param {!FirebaseApp} app - * @return {!FirebaseApp} - */ -export function patchFakeAuthFunctions(app) { - const token_ = null; - - app['INTERNAL'] = app['INTERNAL'] || {}; - - app['INTERNAL']['getToken'] = function(forceRefresh) { - return Promise.resolve(token_); - }; - - app['INTERNAL']['addAuthTokenListener'] = function(listener) {}; - - app['INTERNAL']['removeAuthTokenListener'] = function(listener) {}; - - return app; -} +// mock authentication functions for testing +(firebase as _FirebaseNamespace).INTERNAL.registerComponent( + new Component( + 'auth-internal', + () => ({ + getToken: async () => null, + addAuthTokenListener: () => {}, + removeAuthTokenListener: () => {}, + getUid: () => null + }), + ComponentType.PRIVATE + ) +); export function createTestApp() { const app = firebase.initializeApp({ databaseURL: DATABASE_URL }); - patchFakeAuthFunctions(app); return app; } @@ -94,7 +88,6 @@ export function getRootNode(i = 0, ref?: string) { app = firebase.app('TEST-' + i); } catch (e) { app = firebase.initializeApp({ databaseURL: DATABASE_URL }, 'TEST-' + i); - patchFakeAuthFunctions(app); } db = app.database(); return db.ref(ref); @@ -148,59 +141,6 @@ export function shuffle(arr, randFn = Math.random) { } } -export function testAuthTokenProvider(app) { - let token_ = null; - let nextToken_ = null; - let hasNextToken_ = false; - const listeners_ = []; - - app['INTERNAL'] = app['INTERNAL'] || {}; - - app['INTERNAL']['getToken'] = function(forceRefresh) { - if (forceRefresh && hasNextToken_) { - token_ = nextToken_; - hasNextToken_ = false; - } - return Promise.resolve({ accessToken: token_ }); - }; - - app['INTERNAL']['addAuthTokenListener'] = function(listener) { - const token = token_; - listeners_.push(listener); - const async = Promise.resolve(); - async.then(function() { - listener(token); - }); - }; - - app['INTERNAL']['removeAuthTokenListener'] = function(listener) { - throw Error('removeAuthTokenListener not supported in testing'); - }; - - return { - setToken: function(token) { - token_ = token; - const async = Promise.resolve(); - for (let i = 0; i < listeners_.length; i++) { - async.then( - (function(idx) { - return function() { - listeners_[idx](token); - }; - })(i) - ); - } - - // Any future thens are guaranteed to be resolved after the listeners have been notified - return async; - }, - setNextToken: function(token) { - nextToken_ = token; - hasNextToken_ = true; - } - }; -} - let freshRepoId = 1; const activeFreshApps = []; @@ -209,7 +149,6 @@ export function getFreshRepo(path) { { databaseURL: DATABASE_URL }, 'ISOLATED_REPO_' + freshRepoId++ ); - patchFakeAuthFunctions(app); activeFreshApps.push(app); return (app as any).database().ref(path); } diff --git a/packages/firebase/externs/firebase-app-internal-externs.js b/packages/firebase/externs/firebase-app-internal-externs.js index fc1ceba92c1..c900b2d6b33 100644 --- a/packages/firebase/externs/firebase-app-internal-externs.js +++ b/packages/firebase/externs/firebase-app-internal-externs.js @@ -20,21 +20,9 @@ */ /** - * @param {string} name Service name - * @param {!firebase.ServiceFactory} createService - * @param {Object=} serviceProperties - * @param {(function(string, !firebase.app.App): void)=} appHook - * @param {boolean=} allowMultipleInstances Whether the service registered - * supports multiple instances on the same app. - * @return {firebase.ServiceNamespace} - */ -firebase.INTERNAL.registerService = function( - name, - createService, - serviceProperties, - appHook, - allowMultipleInstances -) {}; + * @param {!firebase.FirebaseComponent} + */ +firebase.INTERNAL.registerComponent = function(component) {}; /** @param {!Object} props */ firebase.INTERNAL.extendNamespace = function(props) {}; diff --git a/packages/firestore-types/index.d.ts b/packages/firestore-types/index.d.ts index 0b763f017c0..f446fd7af5e 100644 --- a/packages/firestore-types/index.d.ts +++ b/packages/firestore-types/index.d.ts @@ -42,40 +42,40 @@ export function setLogLevel(logLevel: LogLevel): void; export class FirebaseFirestore { private constructor(); - + settings(settings: Settings): void; - + enablePersistence(settings?: PersistenceSettings): Promise; - + collection(collectionPath: string): CollectionReference; - + doc(documentPath: string): DocumentReference; - + collectionGroup(collectionId: string): Query; - + runTransaction( updateFunction: (transaction: Transaction) => Promise ): Promise; - + batch(): WriteBatch; - + app: any; - + clearPersistence(): Promise; - + enableNetwork(): Promise; - + disableNetwork(): Promise; - + waitForPendingWrites(): Promise; - + onSnapshotsInSync(observer: { next?: (value: void) => void; error?: (error: Error) => void; complete?: () => void; }): () => void; onSnapshotsInSync(onSync: () => void): () => void; - + terminate(): Promise; INTERNAL: { delete: () => Promise }; @@ -86,26 +86,26 @@ export class GeoPoint { readonly latitude: number; readonly longitude: number; - + isEqual(other: GeoPoint): boolean; } export class Timestamp { constructor(seconds: number, nanoseconds: number); - + static now(): Timestamp; - + static fromDate(date: Date): Timestamp; - + static fromMillis(milliseconds: number): Timestamp; readonly seconds: number; readonly nanoseconds: number; toDate(): Date; - + toMillis(): number; - + isEqual(other: Timestamp): boolean; } @@ -119,7 +119,7 @@ export class Blob { public toBase64(): string; public toUint8Array(): Uint8Array; - + isEqual(other: Blob): boolean; } @@ -133,7 +133,7 @@ export class Transaction { data: DocumentData, options?: SetOptions ): Transaction; - + update(documentRef: DocumentReference, data: UpdateData): Transaction; update( documentRef: DocumentReference, @@ -142,7 +142,6 @@ export class Transaction { ...moreFieldsAndValues: any[] ): Transaction; - delete(documentRef: DocumentReference): Transaction; } @@ -162,9 +161,9 @@ export class WriteBatch { value: any, ...moreFieldsAndValues: any[] ): WriteBatch; - + delete(documentRef: DocumentReference): WriteBatch; - + commit(): Promise; } @@ -294,14 +293,13 @@ export class Query { startAt(snapshot: DocumentSnapshot): Query; startAt(...fieldValues: any[]): Query; - + startAfter(snapshot: DocumentSnapshot): Query; startAfter(...fieldValues: any[]): Query; endBefore(snapshot: DocumentSnapshot): Query; endBefore(...fieldValues: any[]): Query; - endAt(snapshot: DocumentSnapshot): Query; endAt(...fieldValues: any[]): Query; @@ -365,39 +363,39 @@ export interface DocumentChange { export class CollectionReference extends Query { private constructor(); - + readonly id: string; readonly parent: DocumentReference | null; readonly path: string; - + doc(documentPath?: string): DocumentReference; - + add(data: DocumentData): Promise; - + isEqual(other: CollectionReference): boolean; } export class FieldValue { private constructor(); - + static serverTimestamp(): FieldValue; - + static delete(): FieldValue; - + static arrayUnion(...elements: any[]): FieldValue; - + static arrayRemove(...elements: any[]): FieldValue; - + static increment(n: number): FieldValue; - + isEqual(other: FieldValue): boolean; } export class FieldPath { constructor(...fieldNames: string[]); - + static documentId(): FieldPath; - + isEqual(other: FieldPath): boolean; } @@ -425,3 +423,9 @@ export interface FirestoreError { name: string; stack?: string; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'firestore': FirebaseFirestore; + } +} diff --git a/packages/firestore/index.node.ts b/packages/firestore/index.node.ts index a4fba26f35a..138bf92b8dc 100644 --- a/packages/firestore/index.node.ts +++ b/packages/firestore/index.node.ts @@ -14,12 +14,11 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - import firebase from '@firebase/app'; -import { FirebaseNamespace } from '@firebase/app-types'; import * as types from '@firebase/firestore-types'; import { configureForFirebase } from './src/platform/config'; import './src/platform_node/node_init'; +import { FirebaseNamespace } from '@firebase/app-types'; export function registerFirestore(instance: FirebaseNamespace): void { configureForFirebase(instance); diff --git a/packages/firestore/index.ts b/packages/firestore/index.ts index 3070762f006..22773204b84 100644 --- a/packages/firestore/index.ts +++ b/packages/firestore/index.ts @@ -16,12 +16,11 @@ */ import firebase from '@firebase/app'; -import { FirebaseNamespace } from '@firebase/app-types'; -import { Firestore } from './src/api/database'; import { configureForFirebase } from './src/platform/config'; import './src/platform_browser/browser_init'; import * as types from '@firebase/firestore-types'; +import { FirebaseNamespace } from '@firebase/app-types'; export function registerFirestore(instance: FirebaseNamespace): void { configureForFirebase(instance); diff --git a/packages/firestore/package.json b/packages/firestore/package.json index be33bf33b35..7124b5f3f95 100644 --- a/packages/firestore/package.json +++ b/packages/firestore/package.json @@ -35,6 +35,7 @@ "@firebase/webchannel-wrapper": "0.2.32", "@grpc/proto-loader": "^0.5.0", "@firebase/util": "0.2.34", + "@firebase/component": "0.1.0", "grpc": "1.24.2", "tslib": "1.10.0" }, diff --git a/packages/firestore/src/api/credentials.ts b/packages/firestore/src/api/credentials.ts index e411933fc65..928c386e34c 100644 --- a/packages/firestore/src/api/credentials.ts +++ b/packages/firestore/src/api/credentials.ts @@ -15,11 +15,15 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { _FirebaseApp } from '@firebase/app-types/private'; import { User } from '../auth/user'; import { assert } from '../util/assert'; import { Code, FirestoreError } from '../util/error'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; // TODO(mikelehen): This should be split into multiple files and probably // moved to an auth/ folder to match other platforms. @@ -148,7 +152,9 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { private forceRefresh = false; - constructor(private readonly app: FirebaseApp) { + private auth: FirebaseAuthInternal | null; + + constructor(authProvider: Provider) { this.tokenListener = () => { this.tokenCounter++; this.currentUser = this.getUser(); @@ -160,10 +166,26 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { this.tokenCounter = 0; - // Will fire at least once where we set this.currentUser - (this.app as _FirebaseApp).INTERNAL.addAuthTokenListener( - this.tokenListener - ); + this.auth = authProvider.getImmediate({ optional: true }); + + if (this.auth) { + this.auth.addAuthTokenListener(this.tokenListener!); + } else { + // if auth is not available, invoke tokenListener once with null token + this.tokenListener(null); + authProvider.get().then( + auth => { + this.auth = auth; + if (this.tokenListener) { + // tokenListener can be removed by removeChangeListener() + this.auth.addAuthTokenListener(this.tokenListener); + } + }, + () => { + /* this.authProvider.get() never rejects */ + } + ); + } } getToken(): Promise { @@ -178,29 +200,32 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { const initialTokenCounter = this.tokenCounter; const forceRefresh = this.forceRefresh; this.forceRefresh = false; - return (this.app as _FirebaseApp).INTERNAL.getToken(forceRefresh).then( - tokenData => { - // Cancel the request since the token changed while the request was - // outstanding so the response is potentially for a previous user (which - // user, we can't be sure). - if (this.tokenCounter !== initialTokenCounter) { - throw new FirestoreError( - Code.ABORTED, - 'getToken aborted due to token change.' + + if (!this.auth) { + return Promise.resolve(null); + } + + return this.auth.getToken(forceRefresh).then(tokenData => { + // Cancel the request since the token changed while the request was + // outstanding so the response is potentially for a previous user (which + // user, we can't be sure). + if (this.tokenCounter !== initialTokenCounter) { + throw new FirestoreError( + Code.ABORTED, + 'getToken aborted due to token change.' + ); + } else { + if (tokenData) { + assert( + typeof tokenData.accessToken === 'string', + 'Invalid tokenData returned from getToken():' + tokenData ); + return new OAuthToken(tokenData.accessToken, this.currentUser); } else { - if (tokenData) { - assert( - typeof tokenData.accessToken === 'string', - 'Invalid tokenData returned from getToken():' + tokenData - ); - return new OAuthToken(tokenData.accessToken, this.currentUser); - } else { - return null; - } + return null; } } - ); + }); } invalidateToken(): void { @@ -223,9 +248,10 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { this.changeListener !== null, 'removeChangeListener() called when no listener registered' ); - (this.app as _FirebaseApp).INTERNAL.removeAuthTokenListener( - this.tokenListener! - ); + + if (this.auth) { + this.auth.removeAuthTokenListener(this.tokenListener!); + } this.tokenListener = null; this.changeListener = null; } @@ -235,7 +261,7 @@ export class FirebaseCredentialsProvider implements CredentialsProvider { // This method should only be called in the AuthTokenListener callback // to guarantee to get the actual user. private getUser(): User { - const currentUid = (this.app as _FirebaseApp).INTERNAL.getUid(); + const currentUid = this.auth && this.auth.getUid(); assert( currentUid === null || typeof currentUid === 'string', 'Received invalid UID: ' + currentUid diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index bd278bfbb9e..a2a96fab245 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -105,6 +105,8 @@ import { fieldPathFromArgument, UserDataConverter } from './user_data_converter'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; // settings() defaults: const DEFAULT_HOST = 'firestore.googleapis.com'; @@ -308,7 +310,10 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { readonly _dataConverter: UserDataConverter; - constructor(databaseIdOrApp: FirestoreDatabase | FirebaseApp) { + constructor( + databaseIdOrApp: FirestoreDatabase | FirebaseApp, + authProvider: Provider + ) { if (typeof (databaseIdOrApp as FirebaseApp).options === 'object') { // This is very likely a Firebase app object // TODO(b/34177605): Can we somehow use instanceof? @@ -316,7 +321,7 @@ export class Firestore implements firestore.FirebaseFirestore, FirebaseService { this._firebaseApp = app; this._databaseId = Firestore.databaseIdFromApp(app); this._persistenceKey = app.name; - this._credentials = new FirebaseCredentialsProvider(app); + this._credentials = new FirebaseCredentialsProvider(authProvider); } else { const external = databaseIdOrApp as FirestoreDatabase; if (!external.projectId) { diff --git a/packages/firestore/src/platform/config.ts b/packages/firestore/src/platform/config.ts index 6724606e6b1..996d90287ea 100644 --- a/packages/firestore/src/platform/config.ts +++ b/packages/firestore/src/platform/config.ts @@ -15,7 +15,7 @@ * limitations under the License. */ -import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; +import { FirebaseNamespace } from '@firebase/app-types'; import { _FirebaseNamespace } from '@firebase/app-types/private'; import { PublicBlob } from '../api/blob'; import { @@ -36,6 +36,7 @@ import { PublicFieldValue } from '../api/field_value'; import { GeoPoint } from '../api/geo_point'; import { Timestamp } from '../api/timestamp'; import { shallowCopy } from '../util/obj'; +import { Component, ComponentType } from '@firebase/component'; const firestoreNamespace = { Firestore: PublicFirestore, @@ -60,10 +61,15 @@ const firestoreNamespace = { * Configures Firestore as part of the Firebase SDK by calling registerService. */ export function configureForFirebase(firebase: FirebaseNamespace): void { - (firebase as _FirebaseNamespace).INTERNAL.registerService( - 'firestore', - (app: FirebaseApp) => new Firestore(app), - shallowCopy(firestoreNamespace) + (firebase as _FirebaseNamespace).INTERNAL.registerComponent( + new Component( + 'firestore', + container => { + const app = container.getProvider('app').getImmediate()!; + return new Firestore(app, container.getProvider('auth-internal')); + }, + ComponentType.PUBLIC + ).setServiceProps(shallowCopy(firestoreNamespace)) ); } diff --git a/packages/firestore/test/unit/local/remote_document_cache.test.ts b/packages/firestore/test/unit/local/remote_document_cache.test.ts index 4fcbcd443d2..c2ef09b64a7 100644 --- a/packages/firestore/test/unit/local/remote_document_cache.test.ts +++ b/packages/firestore/test/unit/local/remote_document_cache.test.ts @@ -175,9 +175,9 @@ describe('IndexedDbRemoteDocumentCache', () => { ); }); - genericRemoteDocumentCacheTests(() => Promise.resolve(cache)); + genericRemoteDocumentCacheTests(async () => cache); - lruRemoteDocumentCacheTests(() => Promise.resolve(cache)); + lruRemoteDocumentCacheTests(async () => cache); }); function eagerRemoteDocumentCacheTests( diff --git a/packages/firestore/test/util/api_helpers.ts b/packages/firestore/test/util/api_helpers.ts index 4c3fa4b0897..ce53e02d674 100644 --- a/packages/firestore/test/util/api_helpers.ts +++ b/packages/firestore/test/util/api_helpers.ts @@ -37,14 +37,18 @@ import { Document } from '../../src/model/document'; import { DocumentSet } from '../../src/model/document_set'; import { JsonObject } from '../../src/model/field_value'; import { doc, key, path as pathFrom } from './helpers'; +import { Provider, ComponentContainer } from '@firebase/component'; /** * A mock Firestore. Will not work for integration test. */ -export const FIRESTORE = new Firestore({ - projectId: 'projectid', - database: 'database' -}); +export const FIRESTORE = new Firestore( + { + projectId: 'projectid', + database: 'database' + }, + new Provider('auth-internal', new ComponentContainer('default')) +); export function firestore(): Firestore { return FIRESTORE; diff --git a/packages/functions-types/index.d.ts b/packages/functions-types/index.d.ts index d43291dabb4..d0ffb8dc2d6 100644 --- a/packages/functions-types/index.d.ts +++ b/packages/functions-types/index.d.ts @@ -133,3 +133,9 @@ export interface HttpsError extends Error { */ readonly details?: any; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'functions': FirebaseFunctions; + } +} diff --git a/packages/functions/index.node.ts b/packages/functions/index.node.ts index a0117152ea7..3e30f5b452d 100644 --- a/packages/functions/index.node.ts +++ b/packages/functions/index.node.ts @@ -15,37 +15,8 @@ * limitations under the License. */ import firebase from '@firebase/app'; -import { FirebaseApp } from '@firebase/app-types'; -import { - FirebaseServiceFactory, - _FirebaseNamespace -} from '@firebase/app-types/private'; -import { Service } from './src/api/service'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; +import { registerFunctions } from './src/config'; import 'isomorphic-fetch'; -/** - * Type constant for Firebase Functions. - */ -const FUNCTIONS_TYPE = 'functions'; - -function factory(app: FirebaseApp, _unused: unknown, region?: string): Service { - return new Service(app, region); -} - -export function registerFunctions(instance: _FirebaseNamespace): void { - const namespaceExports = { - // no-inline - Functions: Service - }; - instance.INTERNAL.registerService( - FUNCTIONS_TYPE, - factory as FirebaseServiceFactory, - namespaceExports, - // We don't need to wait on any AppHooks. - undefined, - // Allow multiple functions instances per app. - true - ); -} - registerFunctions(firebase as _FirebaseNamespace); diff --git a/packages/functions/index.ts b/packages/functions/index.ts index a8a43581df7..7c4c6357cc3 100644 --- a/packages/functions/index.ts +++ b/packages/functions/index.ts @@ -15,42 +15,9 @@ * limitations under the License. */ import firebase from '@firebase/app'; -import * as appTypes from '@firebase/app-types'; -import { - FirebaseServiceFactory, - _FirebaseNamespace -} from '@firebase/app-types/private'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import * as types from '@firebase/functions-types'; -import { Service } from './src/api/service'; - -/** - * Type constant for Firebase Functions. - */ -const FUNCTIONS_TYPE = 'functions'; - -function factory( - app: appTypes.FirebaseApp, - _unused: unknown, - region?: string -): Service { - return new Service(app, region); -} - -export function registerFunctions(instance: _FirebaseNamespace): void { - const namespaceExports = { - // no-inline - Functions: Service - }; - instance.INTERNAL.registerService( - FUNCTIONS_TYPE, - factory as FirebaseServiceFactory, - namespaceExports, - // We don't need to wait on any AppHooks. - undefined, - // Allow multiple functions instances per app. - true - ); -} +import { registerFunctions } from './src/config'; registerFunctions(firebase as _FirebaseNamespace); diff --git a/packages/functions/package.json b/packages/functions/package.json index f7ab9d36f18..6fbc0f30060 100644 --- a/packages/functions/package.json +++ b/packages/functions/package.json @@ -45,6 +45,7 @@ "dependencies": { "@firebase/functions-types": "0.3.11", "@firebase/messaging-types": "0.3.5", + "@firebase/component": "0.1.0", "isomorphic-fetch": "2.2.1", "tslib": "1.10.0" }, diff --git a/packages/functions/src/api/service.ts b/packages/functions/src/api/service.ts index 8a96c22e6cd..37fc2111fdc 100644 --- a/packages/functions/src/api/service.ts +++ b/packages/functions/src/api/service.ts @@ -26,6 +26,9 @@ import { import { _errorForResponse, HttpsErrorImpl } from './error'; import { ContextProvider } from '../context'; import { Serializer } from '../serializer'; +import { Provider } from '@firebase/component'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { FirebaseMessagingName } from '@firebase/messaging-types'; /** * The response to an http request. @@ -80,9 +83,11 @@ export class Service implements FirebaseFunctions, FirebaseService { */ constructor( private app_: FirebaseApp, + authProvider: Provider, + messagingProvider: Provider, private region_: string = 'us-central1' ) { - this.contextProvider = new ContextProvider(app_); + this.contextProvider = new ContextProvider(authProvider, messagingProvider); // Cancels all ongoing requests when resolved. this.cancelAllRequests = new Promise(resolve => { this.deleteService = () => { diff --git a/packages/functions/src/config.ts b/packages/functions/src/config.ts new file mode 100644 index 00000000000..f021e1fbcd7 --- /dev/null +++ b/packages/functions/src/config.ts @@ -0,0 +1,51 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { Service } from './api/service'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; + +/** + * Type constant for Firebase Functions. + */ +const FUNCTIONS_TYPE = 'functions'; + +function factory(container: ComponentContainer, region?: string): Service { + // Dependencies + const app = container.getProvider('app').getImmediate(); + const authProvider = container.getProvider('auth-internal'); + const messagingProvider = container.getProvider('messaging'); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return new Service(app, authProvider, messagingProvider, region); +} + +export function registerFunctions(instance: _FirebaseNamespace): void { + const namespaceExports = { + // no-inline + Functions: Service + }; + instance.INTERNAL.registerComponent( + new Component(FUNCTIONS_TYPE, factory, ComponentType.PUBLIC) + .setServiceProps(namespaceExports) + .setMultipleInstances(true) + ); +} diff --git a/packages/functions/src/context.ts b/packages/functions/src/context.ts index b3bd5a7cc2e..cb570d6a771 100644 --- a/packages/functions/src/context.ts +++ b/packages/functions/src/context.ts @@ -14,9 +14,16 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { _FirebaseApp } from '@firebase/app-types/private'; -import { FirebaseMessaging } from '@firebase/messaging-types'; +import { + FirebaseMessaging, + FirebaseMessagingName +} from '@firebase/messaging-types'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; /** * The metadata that should be supplied with function calls. @@ -30,11 +37,43 @@ export interface Context { * Helper class to get metadata that should be included with a function call. */ export class ContextProvider { - constructor(private readonly app: FirebaseApp) {} + private auth: FirebaseAuthInternal | null = null; + private messaging: FirebaseMessaging | null = null; + constructor( + authProvider: Provider, + messagingProvider: Provider + ) { + this.auth = authProvider.getImmediate({ optional: true }); + this.messaging = messagingProvider.getImmediate({ + optional: true + }); + + if (!this.auth) { + authProvider.get().then( + auth => (this.auth = auth), + () => { + /* get() never rejects */ + } + ); + } + + if (!this.messaging) { + messagingProvider.get().then( + messaging => (this.messaging = messaging), + () => { + /* get() never rejects */ + } + ); + } + } async getAuthToken(): Promise { + if (!this.auth) { + return undefined; + } + try { - const token = await (this.app as _FirebaseApp).INTERNAL.getToken(); + const token = await this.auth.getToken(); if (!token) { return undefined; } @@ -47,15 +86,11 @@ export class ContextProvider { async getInstanceIdToken(): Promise { try { - // HACK: Until we have a separate instanceId package, this is a quick way - // to load in the messaging instance for this app. - // eslint-disable-next-line @typescript-eslint/no-explicit-any - if (!(this.app as any).messaging) { + if (!this.messaging) { return undefined; } - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const messaging = (this.app as any).messaging() as FirebaseMessaging; - const token = await messaging.getToken(); + + const token = await this.messaging.getToken(); if (!token) { return undefined; } diff --git a/packages/functions/test/browser/callable.test.ts b/packages/functions/test/browser/callable.test.ts index 00e970c1638..4e0280e8705 100644 --- a/packages/functions/test/browser/callable.test.ts +++ b/packages/functions/test/browser/callable.test.ts @@ -18,48 +18,52 @@ import { expect } from 'chai'; import * as sinon from 'sinon'; import { FirebaseApp } from '@firebase/app-types'; import { _FirebaseApp } from '@firebase/app-types/private'; -import firebase from '@firebase/app'; -import { Service } from '../../src/api/service'; - -/* eslint-disable import/no-duplicates */ -import '@firebase/messaging'; -import { isSupported } from '@firebase/messaging'; +import { makeFakeApp, createTestService } from '../utils'; +import { + FirebaseMessaging, + FirebaseMessagingName +} from '@firebase/messaging-types'; +import { + Provider, + ComponentContainer, + ComponentType, + Component +} from '@firebase/component'; // eslint-disable-next-line @typescript-eslint/no-require-imports export const TEST_PROJECT = require('../../../../config/project.json'); describe('Firebase Functions > Call', () => { - let app: FirebaseApp; - let functions: Service; - - before(() => { - const projectId = TEST_PROJECT.projectId; - const messagingSenderId = 'messaging-sender-id'; - const region = 'us-central1'; - try { - app = firebase.app('TEST-APP'); - } catch (e) { - app = firebase.initializeApp( - { projectId, messagingSenderId }, - 'TEST-APP' - ); - } - functions = new Service(app, region); + const app: FirebaseApp = makeFakeApp({ + projectId: TEST_PROJECT.projectId, + messagingSenderId: 'messaging-sender-id' }); + const region = 'us-central1'; // TODO(klimt): Move this to the cross-platform tests and delete this file, // once instance id works there. it('instance id', async () => { - if (!isSupported()) { - // Current platform does not support messaging, skip test. - return; - } + // mock firebase messaging + const messagingMock: FirebaseMessaging = ({ + getToken: async () => 'iid' + } as unknown) as FirebaseMessaging; + const messagingProvider = new Provider( + 'messaging', + new ComponentContainer('test') + ); + messagingProvider.setComponent( + new Component('messaging', () => messagingMock, ComponentType.PRIVATE) + ); + + const functions = createTestService( + app, + region, + undefined, + messagingProvider + ); // Stub out the messaging method get an instance id token. - const messaging = firebase.messaging(app); - const stub = sinon - .stub(messaging, 'getToken') - .returns(Promise.resolve('iid')); + const stub = sinon.stub(messagingMock, 'getToken').callThrough(); const func = functions.httpsCallable('instanceIdTest'); const result = await func({}); diff --git a/packages/functions/test/callable.test.ts b/packages/functions/test/callable.test.ts index 9fc9817d1cb..9c0abaa07a5 100644 --- a/packages/functions/test/callable.test.ts +++ b/packages/functions/test/callable.test.ts @@ -19,9 +19,17 @@ import * as sinon from 'sinon'; import { FirebaseApp } from '@firebase/app-types'; import { _FirebaseApp } from '@firebase/app-types/private'; import { FunctionsErrorCode } from '@firebase/functions-types'; -import firebase from '@firebase/app'; -import '@firebase/messaging'; -import { Service } from '../src/api/service'; +import { + Provider, + ComponentContainer, + Component, + ComponentType +} from '@firebase/component'; +import { + FirebaseAuthInternal, + FirebaseAuthInternalName +} from '@firebase/auth-interop-types'; +import { makeFakeApp, createTestService } from './utils'; // eslint-disable-next-line @typescript-eslint/no-require-imports export const TEST_PROJECT = require('../../../config/project.json'); @@ -50,7 +58,7 @@ async function expectError( describe('Firebase Functions > Call', () => { let app: FirebaseApp; - let functions: Service; + const region = 'us-central1'; before(() => { const useEmulator = !!process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN; @@ -58,24 +66,12 @@ describe('Firebase Functions > Call', () => { ? 'functions-integration-test' : TEST_PROJECT.projectId; const messagingSenderId = 'messaging-sender-id'; - const region = 'us-central1'; - try { - app = firebase.app('TEST-APP'); - } catch (e) { - app = firebase.initializeApp( - { projectId, messagingSenderId }, - 'TEST-APP' - ); - } - functions = new Service(app, region); - if (useEmulator) { - functions.useFunctionsEmulator( - process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN! - ); - } + + app = makeFakeApp({ projectId, messagingSenderId }); }); it('simple data', async () => { + const functions = createTestService(app, region); // TODO(klimt): Should we add an API to create a "long" in JS? const data = { bool: true, @@ -96,16 +92,29 @@ describe('Firebase Functions > Call', () => { }); it('scalars', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('scalarTest'); const result = await func(17); expect(result.data).to.equal(76); }); it('token', async () => { - // Stub out the internals to get an auth token. - const stub = sinon.stub((app as _FirebaseApp).INTERNAL, 'getToken'); - stub.returns(Promise.resolve({ accessToken: 'token' })); + // mock auth-internal service + const authMock: FirebaseAuthInternal = ({ + getToken: async () => ({ accessToken: 'token' }) + } as unknown) as FirebaseAuthInternal; + const authProvider = new Provider( + 'auth-internal', + new ComponentContainer('test') + ); + authProvider.setComponent( + new Component('auth-internal', () => authMock, ComponentType.PRIVATE) + ); + + const functions = createTestService(app, region, authProvider); + // Stub out the internals to get an auth token. + const stub = sinon.stub(authMock, 'getToken').callThrough(); const func = functions.httpsCallable('tokenTest'); const result = await func({}); expect(result.data).to.deep.equal({}); @@ -132,6 +141,7 @@ describe('Firebase Functions > Call', () => { */ it('null', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('nullTest'); let result = await func(null); expect(result.data).to.be.null; @@ -142,21 +152,25 @@ describe('Firebase Functions > Call', () => { }); it('missing result', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('missingResultTest'); await expectError(func(), 'internal', 'Response is missing data field.'); }); it('unhandled error', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('unhandledErrorTest'); await expectError(func(), 'internal', 'internal'); }); it('unknown error', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('unknownErrorTest'); await expectError(func(), 'internal', 'internal'); }); it('explicit error', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('explicitErrorTest'); await expectError(func(), 'out-of-range', 'explicit nope', { start: 10, @@ -166,11 +180,13 @@ describe('Firebase Functions > Call', () => { }); it('http error', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('httpErrorTest'); await expectError(func(), 'invalid-argument', 'invalid-argument'); }); it('timeout', async () => { + const functions = createTestService(app, region); const func = functions.httpsCallable('timeoutTest', { timeout: 10 }); await expectError(func(), 'deadline-exceeded', 'deadline-exceeded'); }); diff --git a/packages/functions/test/service.test.ts b/packages/functions/test/service.test.ts index c90749ced12..71ece550379 100644 --- a/packages/functions/test/service.test.ts +++ b/packages/functions/test/service.test.ts @@ -15,7 +15,7 @@ * limitations under the License. */ import { assert } from 'chai'; -import { Service } from '../src/api/service'; +import { createTestService } from './utils'; describe('Firebase Functions > Service', () => { describe('simple constructor', () => { @@ -24,7 +24,7 @@ describe('Firebase Functions > Service', () => { projectId: 'my-project' } }; - const service = new Service(app); + const service = createTestService(app); it('has valid urls', () => { assert.equal( @@ -48,7 +48,7 @@ describe('Firebase Functions > Service', () => { projectId: 'my-project' } }; - const service = new Service(app, 'my-region'); + const service = createTestService(app, 'my-region'); it('has valid urls', () => { assert.equal( diff --git a/packages/functions/test/utils.ts b/packages/functions/test/utils.ts new file mode 100644 index 00000000000..d4da7d2ddde --- /dev/null +++ b/packages/functions/test/utils.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 { FirebaseOptions, FirebaseApp } from '@firebase/app-types'; +import { Provider, ComponentContainer } from '@firebase/component'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; +import { FirebaseMessagingName } from '@firebase/messaging-types'; +import { Service } from '../src/api/service'; + +export function makeFakeApp(options: FirebaseOptions = {}): FirebaseApp { + options = { + apiKey: 'apiKey', + projectId: 'projectId', + authDomain: 'authDomain', + messagingSenderId: '1234567890', + databaseURL: 'databaseUrl', + storageBucket: 'storageBucket', + appId: '1:777777777777:web:d93b5ca1475efe57', + ...options + }; + return { + name: 'appName', + options, + automaticDataCollectionEnabled: true, + delete: async () => {} + }; +} + +export function createTestService( + app: FirebaseApp, + region?: string, + authProvider = new Provider( + 'auth-internal', + new ComponentContainer('test') + ), + messagingProvider = new Provider( + 'messaging', + new ComponentContainer('test') + ) +): Service { + const functions = new Service(app, authProvider, messagingProvider, region); + const useEmulator = !!process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN; + if (useEmulator) { + functions.useFunctionsEmulator( + process.env.FIREBASE_FUNCTIONS_EMULATOR_ORIGIN! + ); + } + return functions; +} diff --git a/packages/installations-types/index.d.ts b/packages/installations-types/index.d.ts index 2ae578e0a4d..a55a7cb378a 100644 --- a/packages/installations-types/index.d.ts +++ b/packages/installations-types/index.d.ts @@ -38,3 +38,11 @@ export interface FirebaseInstallations { */ delete(): Promise; } + +export type FirebaseInstallationsName = 'installations'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'installations': FirebaseInstallations; + } +} diff --git a/packages/installations/package.json b/packages/installations/package.json index 4a1a35399c1..a6970a1b710 100644 --- a/packages/installations/package.json +++ b/packages/installations/package.json @@ -36,6 +36,7 @@ "dependencies": { "@firebase/installations-types": "0.2.2", "@firebase/util": "0.2.34", + "@firebase/component": "0.1.0", "idb": "3.0.2", "tslib": "1.10.0" } diff --git a/packages/installations/src/index.ts b/packages/installations/src/index.ts index abdbc669624..3ad37e924d8 100644 --- a/packages/installations/src/index.ts +++ b/packages/installations/src/index.ts @@ -16,31 +16,34 @@ */ import firebase from '@firebase/app'; -import { - _FirebaseNamespace, - FirebaseServiceFactory -} from '@firebase/app-types/private'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { FirebaseInstallations } from '@firebase/installations-types'; import { deleteInstallation, getId, getToken } from './functions'; import { extractAppConfig } from './helpers/extract-app-config'; +import { Component, ComponentType } from '@firebase/component'; export function registerInstallations(instance: _FirebaseNamespace): void { const installationsName = 'installations'; - const factoryMethod: FirebaseServiceFactory = app => { - // Throws if app isn't configured properly. - extractAppConfig(app); - - return { - app, - getId: () => getId(app), - getToken: (forceRefresh?: boolean) => getToken(app, forceRefresh), - delete: () => deleteInstallation(app) - }; - }; - - instance.INTERNAL.registerService(installationsName, factoryMethod); + instance.INTERNAL.registerComponent( + new Component( + installationsName, + container => { + const app = container.getProvider('app').getImmediate(); + // Throws if app isn't configured properly. + extractAppConfig(app); + + return { + app, + getId: () => getId(app), + getToken: (forceRefresh?: boolean) => getToken(app, forceRefresh), + delete: () => deleteInstallation(app) + }; + }, + ComponentType.PUBLIC + ) + ); } registerInstallations(firebase as _FirebaseNamespace); diff --git a/packages/messaging-types/index.d.ts b/packages/messaging-types/index.d.ts index 3a2d9d8bb9e..8b788af1c20 100644 --- a/packages/messaging-types/index.d.ts +++ b/packages/messaging-types/index.d.ts @@ -49,3 +49,11 @@ export class FirebaseMessaging { useServiceWorker(registration: ServiceWorkerRegistration): void; usePublicVapidKey(b64PublicKey: string): void; } + +export type FirebaseMessagingName = 'messaging'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'messaging': FirebaseMessaging; + } +} diff --git a/packages/messaging/index.ts b/packages/messaging/index.ts index 82990883925..355c2f01997 100644 --- a/packages/messaging/index.ts +++ b/packages/messaging/index.ts @@ -16,30 +16,44 @@ */ import firebase from '@firebase/app'; -import { - _FirebaseNamespace, - FirebaseServiceFactory -} from '@firebase/app-types/private'; +import '@firebase/installations'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { FirebaseMessaging } from '@firebase/messaging-types'; - import { SwController } from './src/controllers/sw-controller'; import { WindowController } from './src/controllers/window-controller'; import { ErrorCode, errorFactory } from './src/models/errors'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; +import { FirebaseInternalServices } from './src/interfaces/internal-services'; export function registerMessaging(instance: _FirebaseNamespace): void { const messagingName = 'messaging'; - const factoryMethod: FirebaseServiceFactory = app => { + const factoryMethod = (container: ComponentContainer): FirebaseMessaging => { + /* Dependencies */ + const app = container.getProvider('app').getImmediate(); + const installations = container.getProvider('installations').getImmediate(); + const analyticsProvider = container.getProvider('analytics-internal'); + + const firebaseServices: FirebaseInternalServices = { + app, + installations, + analyticsProvider + }; + if (!isSupported()) { throw errorFactory.create(ErrorCode.UNSUPPORTED_BROWSER); } if (self && 'ServiceWorkerGlobalScope' in self) { // Running in ServiceWorker context - return new SwController(app); + return new SwController(firebaseServices); } else { // Assume we are in the window context. - return new WindowController(app); + return new WindowController(firebaseServices); } }; @@ -47,10 +61,12 @@ export function registerMessaging(instance: _FirebaseNamespace): void { isSupported }; - instance.INTERNAL.registerService( - messagingName, - factoryMethod, - namespaceExports + instance.INTERNAL.registerComponent( + new Component( + messagingName, + factoryMethod, + ComponentType.PUBLIC + ).setServiceProps(namespaceExports) ); } diff --git a/packages/messaging/package.json b/packages/messaging/package.json index d7dcf15fd51..28165910dd3 100644 --- a/packages/messaging/package.json +++ b/packages/messaging/package.json @@ -29,6 +29,7 @@ "@firebase/installations": "0.3.6", "@firebase/messaging-types": "0.3.5", "@firebase/util": "0.2.34", + "@firebase/component": "0.1.0", "tslib": "1.10.0" }, "devDependencies": { diff --git a/packages/messaging/src/controllers/base-controller.ts b/packages/messaging/src/controllers/base-controller.ts index 7a81f49def3..632fb93355b 100644 --- a/packages/messaging/src/controllers/base-controller.ts +++ b/packages/messaging/src/controllers/base-controller.ts @@ -16,7 +16,10 @@ */ import { FirebaseApp } from '@firebase/app-types'; -import { FirebaseServiceInternals } from '@firebase/app-types/private'; +import { + FirebaseServiceInternals, + FirebaseService +} from '@firebase/app-types/private'; import { FirebaseMessaging } from '@firebase/messaging-types'; import { CompleteFn, @@ -33,6 +36,7 @@ import { ErrorCode, errorFactory } from '../models/errors'; import { SubscriptionManager } from '../models/subscription-manager'; import { TokenDetailsModel } from '../models/token-details-model'; import { VapidDetailsModel } from '../models/vapid-details-model'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; export type BgMessageHandler = ( payload: MessagePayload @@ -41,13 +45,17 @@ export type BgMessageHandler = ( // Token should be refreshed once a week. export const TOKEN_EXPIRATION_MILLIS = 7 * 24 * 60 * 60 * 1000; // 7 days -export abstract class BaseController implements FirebaseMessaging { +export abstract class BaseController + implements FirebaseMessaging, FirebaseService { INTERNAL: FirebaseServiceInternals; + readonly app: FirebaseApp; private readonly tokenDetailsModel: TokenDetailsModel; private readonly vapidDetailsModel = new VapidDetailsModel(); private readonly subscriptionManager = new SubscriptionManager(); - constructor(readonly app: FirebaseApp) { + constructor(protected readonly services: FirebaseInternalServices) { + const { app } = services; + this.app = app; if ( !app.options.messagingSenderId || typeof app.options.messagingSenderId !== 'string' @@ -59,7 +67,7 @@ export abstract class BaseController implements FirebaseMessaging { delete: () => this.delete() }; - this.tokenDetailsModel = new TokenDetailsModel(app); + this.tokenDetailsModel = new TokenDetailsModel(services); } async getToken(): Promise { @@ -147,7 +155,7 @@ export abstract class BaseController implements FirebaseMessaging { try { const updatedToken = await this.subscriptionManager.updateToken( tokenDetails, - this.app, + this.services, pushSubscription, publicVapidKey ); @@ -155,7 +163,7 @@ export abstract class BaseController implements FirebaseMessaging { const allDetails: TokenDetails = { swScope: swReg.scope, vapidKey: publicVapidKey, - fcmSenderId: this.app.options.messagingSenderId!, + fcmSenderId: this.services.app.options.messagingSenderId!, fcmToken: updatedToken, createTime: Date.now(), endpoint: pushSubscription.endpoint, @@ -181,7 +189,7 @@ export abstract class BaseController implements FirebaseMessaging { publicVapidKey: Uint8Array ): Promise { const newToken = await this.subscriptionManager.getToken( - this.app, + this.services, pushSubscription, publicVapidKey ); @@ -228,7 +236,7 @@ export abstract class BaseController implements FirebaseMessaging { */ private async deleteTokenFromDB(token: string): Promise { const tokenDetails = await this.tokenDetailsModel.deleteToken(token); - await this.subscriptionManager.deleteToken(this.app, tokenDetails); + await this.subscriptionManager.deleteToken(this.services, tokenDetails); } // Visible for testing diff --git a/packages/messaging/src/controllers/sw-controller.ts b/packages/messaging/src/controllers/sw-controller.ts index 5667cf7a63c..8c2b291550b 100644 --- a/packages/messaging/src/controllers/sw-controller.ts +++ b/packages/messaging/src/controllers/sw-controller.ts @@ -16,9 +16,6 @@ */ import './sw-types'; - -import { FirebaseApp } from '@firebase/app-types'; - import { MessagePayload, NotificationDetails @@ -30,6 +27,7 @@ import { } from '../models/fcm-details'; import { InternalMessage, MessageType } from '../models/worker-page-message'; import { BaseController, BgMessageHandler } from './base-controller'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; // Let TS know that this is a service worker declare const self: ServiceWorkerGlobalScope; @@ -39,8 +37,8 @@ const FCM_MSG = 'FCM_MSG'; export class SwController extends BaseController { private bgMessageHandler: BgMessageHandler | null = null; - constructor(app: FirebaseApp) { - super(app); + constructor(services: FirebaseInternalServices) { + super(services); self.addEventListener('push', e => { this.onPush(e); diff --git a/packages/messaging/src/controllers/window-controller.ts b/packages/messaging/src/controllers/window-controller.ts index 7f990ad160d..b9dad6ac94e 100644 --- a/packages/messaging/src/controllers/window-controller.ts +++ b/packages/messaging/src/controllers/window-controller.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { _FirebaseApp } from '@firebase/app-types/private'; import { CompleteFn, @@ -39,6 +38,7 @@ import { } from '../models/fcm-details'; import { InternalMessage, MessageType } from '../models/worker-page-message'; import { BaseController } from './base-controller'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; export class WindowController extends BaseController { private registrationToUse: ServiceWorkerRegistration | null = null; @@ -63,8 +63,8 @@ export class WindowController extends BaseController { /** * A service that provides a MessagingService instance. */ - constructor(app: FirebaseApp) { - super(app); + constructor(services: FirebaseInternalServices) { + super(services); this.setupSWMessageListener_(); } @@ -280,7 +280,7 @@ export class WindowController extends BaseController { setupSWMessageListener_(): void { navigator.serviceWorker.addEventListener( 'message', - event => { + async event => { if ( !event.data || !event.data.firebaseMessagingType || @@ -308,7 +308,8 @@ export class WindowController extends BaseController { // This message has a campaign id, meaning it was sent using the FN Console. // Analytics is enabled on this message, so we should log it. const eventType = getEventType(firebaseMessagingType); - (this.app as _FirebaseApp).INTERNAL.analytics.logEvent( + const analytics = await this.services.analyticsProvider.get(); + analytics.logEvent( eventType, /* eslint-disable camelcase */ { diff --git a/packages/messaging/src/interfaces/internal-services.ts b/packages/messaging/src/interfaces/internal-services.ts new file mode 100644 index 00000000000..7d448cc1102 --- /dev/null +++ b/packages/messaging/src/interfaces/internal-services.ts @@ -0,0 +1,27 @@ +/** + * @license + * Copyright 2019 Google Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { Provider } from '@firebase/component'; + +export interface FirebaseInternalServices { + app: FirebaseApp; + installations: FirebaseInstallations; + analyticsProvider: Provider; +} diff --git a/packages/messaging/src/models/clean-v1-undefined.ts b/packages/messaging/src/models/clean-v1-undefined.ts index 7952d338934..bbedc93c4b6 100644 --- a/packages/messaging/src/models/clean-v1-undefined.ts +++ b/packages/messaging/src/models/clean-v1-undefined.ts @@ -28,12 +28,12 @@ */ import { SubscriptionManager } from './subscription-manager'; -import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; const OLD_DB_NAME = 'undefined'; const OLD_OBJECT_STORE_NAME = 'fcm_token_object_Store'; -function handleDb(db: IDBDatabase, app: FirebaseApp): void { +function handleDb(db: IDBDatabase, services: FirebaseInternalServices): void { if (!db.objectStoreNames.contains(OLD_OBJECT_STORE_NAME)) { // We found a database with the name 'undefined', but our expected object // store isn't defined. @@ -59,7 +59,7 @@ function handleDb(db: IDBDatabase, app: FirebaseApp): void { const tokenDetails = cursor.value; // eslint-disable-next-line @typescript-eslint/no-floating-promises - subscriptionManager.deleteToken(app, tokenDetails); + subscriptionManager.deleteToken(services, tokenDetails); cursor.continue(); } else { @@ -69,13 +69,13 @@ function handleDb(db: IDBDatabase, app: FirebaseApp): void { }; } -export function cleanV1(app: FirebaseApp): void { +export function cleanV1(services: FirebaseInternalServices): void { const request: IDBOpenDBRequest = indexedDB.open(OLD_DB_NAME); request.onerror = _event => { // NOOP - Nothing we can do. }; request.onsuccess = _event => { const db = request.result; - handleDb(db, app); + handleDb(db, services); }; } diff --git a/packages/messaging/src/models/subscription-manager.ts b/packages/messaging/src/models/subscription-manager.ts index 54c871d13c6..16a62732419 100644 --- a/packages/messaging/src/models/subscription-manager.ts +++ b/packages/messaging/src/models/subscription-manager.ts @@ -20,8 +20,8 @@ import { isArrayBufferEqual } from '../helpers/is-array-buffer-equal'; import { ErrorCode, errorFactory } from './errors'; import { DEFAULT_PUBLIC_VAPID_KEY, ENDPOINT } from './fcm-details'; import { FirebaseApp } from '@firebase/app-types'; -import '@firebase/installations'; import { TokenDetails } from '../interfaces/token-details'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; interface ApiResponse { token?: string; @@ -39,11 +39,11 @@ interface TokenRequestBody { export class SubscriptionManager { async getToken( - app: FirebaseApp, + services: FirebaseInternalServices, subscription: PushSubscription, vapidKey: Uint8Array ): Promise { - const headers = await getHeaders(app); + const headers = await getHeaders(services); const body = getBody(subscription, vapidKey); const subscribeOptions = { @@ -54,7 +54,7 @@ export class SubscriptionManager { let responseData: ApiResponse; try { - const response = await fetch(getEndpoint(app), subscribeOptions); + const response = await fetch(getEndpoint(services.app), subscribeOptions); responseData = await response.json(); } catch (err) { throw errorFactory.create(ErrorCode.TOKEN_SUBSCRIBE_FAILED, { @@ -81,11 +81,11 @@ export class SubscriptionManager { */ async updateToken( tokenDetails: TokenDetails, - app: FirebaseApp, + services: FirebaseInternalServices, subscription: PushSubscription, vapidKey: Uint8Array ): Promise { - const headers = await getHeaders(app); + const headers = await getHeaders(services); const body = getBody(subscription, vapidKey); const updateOptions = { @@ -97,7 +97,7 @@ export class SubscriptionManager { let responseData: ApiResponse; try { const response = await fetch( - `${getEndpoint(app)}/${tokenDetails.fcmToken}`, + `${getEndpoint(services.app)}/${tokenDetails.fcmToken}`, updateOptions ); responseData = await response.json(); @@ -122,11 +122,11 @@ export class SubscriptionManager { } async deleteToken( - app: FirebaseApp, + services: FirebaseInternalServices, tokenDetails: TokenDetails ): Promise { // TODO: Add FIS header - const headers = await getHeaders(app); + const headers = await getHeaders(services); const unsubscribeOptions = { method: 'DELETE', @@ -135,7 +135,7 @@ export class SubscriptionManager { try { const response = await fetch( - `${getEndpoint(app)}/${tokenDetails.fcmToken}`, + `${getEndpoint(services.app)}/${tokenDetails.fcmToken}`, unsubscribeOptions ); const responseData: ApiResponse = await response.json(); @@ -157,8 +157,10 @@ function getEndpoint(app: FirebaseApp): string { return `${ENDPOINT}/projects/${app.options.projectId!}/registrations`; } -async function getHeaders(app: FirebaseApp): Promise { - const installations = app.installations(); +async function getHeaders({ + app, + installations +}: FirebaseInternalServices): Promise { const authToken = await installations.getToken(); return new Headers({ diff --git a/packages/messaging/src/models/token-details-model.ts b/packages/messaging/src/models/token-details-model.ts index 6ae682e54c6..46c29ad9f3d 100644 --- a/packages/messaging/src/models/token-details-model.ts +++ b/packages/messaging/src/models/token-details-model.ts @@ -20,14 +20,14 @@ import { TokenDetails } from '../interfaces/token-details'; import { cleanV1 } from './clean-v1-undefined'; import { DbInterface } from './db-interface'; import { ErrorCode, errorFactory } from './errors'; -import { FirebaseApp } from '@firebase/app-types'; +import { FirebaseInternalServices } from '../interfaces/internal-services'; export class TokenDetailsModel extends DbInterface { protected readonly dbName: string = 'fcm_token_details_db'; protected readonly dbVersion: number = 4; protected readonly objectStoreName: string = 'fcm_token_object_Store'; - constructor(private readonly app: FirebaseApp) { + constructor(private readonly services: FirebaseInternalServices) { super(); } @@ -57,7 +57,7 @@ export class TokenDetailsModel extends DbInterface { // Prior to version 2, we were using either 'fcm_token_details_db' // or 'undefined' as the database name due to bug in the SDK // So remove the old tokens and databases. - cleanV1(this.app); + cleanV1(this.services); } case 2: { diff --git a/packages/messaging/test/constructor.test.ts b/packages/messaging/test/constructor.test.ts index 4f3f371720c..3984e5c071d 100644 --- a/packages/messaging/test/constructor.test.ts +++ b/packages/messaging/test/constructor.test.ts @@ -22,9 +22,15 @@ import { SwController } from '../src/controllers/sw-controller'; import { WindowController } from '../src/controllers/window-controller'; import { ErrorCode } from '../src/models/errors'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { + makeFakeApp, + makeFakeInstallations, + makeFakeAnalyticsProvider +} from './testing-utils/make-fake-firebase-services'; describe('Firebase Messaging > new *Controller()', () => { + const analyticsProvider = makeFakeAnalyticsProvider(); + const installations = makeFakeInstallations(); it('should handle bad input', () => { const badInputs = [ makeFakeApp({ @@ -45,8 +51,12 @@ describe('Firebase Messaging > new *Controller()', () => { ]; badInputs.forEach(badInput => { try { - new WindowController(badInput); - new SwController(badInput); + new WindowController({ + app: badInput, + installations, + analyticsProvider + }); + new SwController({ app: badInput, installations, analyticsProvider }); assert.fail( `Bad Input should have thrown: ${JSON.stringify(badInput)}` @@ -60,7 +70,7 @@ describe('Firebase Messaging > new *Controller()', () => { it('should be able to handle good input', () => { const app = makeFakeApp(); - new WindowController(app); - new SwController(app); + new WindowController({ app, installations, analyticsProvider }); + new SwController({ app, installations, analyticsProvider }); }); }); diff --git a/packages/messaging/test/controller-delete-token.test.ts b/packages/messaging/test/controller-delete-token.test.ts index 4d9d60b9fdc..3cece088d43 100644 --- a/packages/messaging/test/controller-delete-token.test.ts +++ b/packages/messaging/test/controller-delete-token.test.ts @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { assert } from 'chai'; import { stub, restore } from 'sinon'; @@ -26,16 +25,17 @@ import { SubscriptionManager } from '../src/models/subscription-manager'; import { TokenDetailsModel } from '../src/models/token-details-model'; import { deleteDatabase } from './testing-utils/db-helper'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSubscription } from './testing-utils/make-fake-subscription'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; import { TokenDetails } from '../src/interfaces/token-details'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; let FAKE_SUBSCRIPTION: PushSubscription; let EXAMPLE_TOKEN_SAVE: TokenDetails; describe('Firebase Messaging > *Controller.deleteToken()', () => { - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; let messagingService: WindowController | SwController; function configureRegistrationMocks( @@ -77,7 +77,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { }); beforeEach(() => { - app = makeFakeApp({ + firebaseInternalServices = makeFakeFirebaseInternalServices({ messagingSenderId: EXAMPLE_TOKEN_SAVE.fcmSenderId }); }); @@ -93,7 +93,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { }); it('should handle no token to delete', () => { - messagingService = new WindowController(app); + messagingService = new WindowController(firebaseInternalServices); return messagingService.deleteToken(undefined as any).then( () => { throw new Error('Expected error to be thrown.'); @@ -117,7 +117,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { 'deleteToken' ).callsFake(async () => {}); - messagingService = new WindowController(app); + messagingService = new WindowController(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken); }); @@ -139,7 +139,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { 'deleteToken' ).callsFake(async () => {}); - messagingService = new WindowController(app); + messagingService = new WindowController(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken).then( () => { throw new Error('Expected this to reject'); @@ -169,7 +169,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { 'deleteToken' ).callsFake(async () => {}); - messagingService = new serviceClass(app); + messagingService = new serviceClass(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken); }); @@ -199,7 +199,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { 'deleteToken' ).callsFake(async () => {}); - messagingService = new serviceClass(app); + messagingService = new serviceClass(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken).then( () => { throw new Error('Expected this to reject'); @@ -233,7 +233,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { throw new Error(errorMsg); }); - messagingService = new serviceClass(app); + messagingService = new serviceClass(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken).then( () => { throw new Error('Expected this to reject'); @@ -267,7 +267,7 @@ describe('Firebase Messaging > *Controller.deleteToken()', () => { 'deleteToken' ).callsFake(async () => {}); - messagingService = new serviceClass(app); + messagingService = new serviceClass(firebaseInternalServices); return messagingService.deleteToken(EXAMPLE_TOKEN_SAVE.fcmToken); }); }); diff --git a/packages/messaging/test/controller-get-token.test.ts b/packages/messaging/test/controller-get-token.test.ts index cb55b6b844d..9e17c652690 100644 --- a/packages/messaging/test/controller-get-token.test.ts +++ b/packages/messaging/test/controller-get-token.test.ts @@ -17,8 +17,6 @@ import { assert, expect } from 'chai'; import { stub, restore, useFakeTimers } from 'sinon'; -import { FirebaseApp } from '@firebase/app-types'; - import { BaseController } from '../src/controllers/base-controller'; import { SwController } from '../src/controllers/sw-controller'; import { WindowController } from '../src/controllers/window-controller'; @@ -31,9 +29,10 @@ import { SubscriptionManager } from '../src/models/subscription-manager'; import { TokenDetailsModel } from '../src/models/token-details-model'; import { VapidDetailsModel } from '../src/models/vapid-details-model'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSubscription } from './testing-utils/make-fake-subscription'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; const ONE_DAY = 24 * 60 * 60 * 1000; @@ -73,7 +72,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { let EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID: TokenDetails; let EXAMPLE_EXPIRED_TOKEN_DETAILS: TokenDetails; - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; beforeEach(() => { now = Date.now(); @@ -124,7 +123,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { createTime: expiredDate }; - app = makeFakeApp({ + firebaseInternalServices = makeFakeFirebaseInternalServices({ messagingSenderId: EXAMPLE_SENDER_ID }); }); @@ -142,7 +141,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { Promise.reject('No Service Worker') ); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); try { await messagingService.getToken(); throw new Error('Expected getToken to throw '); @@ -166,7 +165,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { notificationStub.onCall(3).returns('default'); return servicesToTest.reduce(async (chain, serviceClass) => { - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); stub(serviceClass.prototype, 'getPublicVapidKey_').callsFake(() => Promise.resolve(DEFAULT_PUBLIC_VAPID_KEY) ); @@ -200,8 +199,8 @@ describe('Firebase Messaging > *Controller.getToken()', () => { details = EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID; } - stub(serviceClass.prototype, 'getPublicVapidKey_').callsFake(() => - Promise.resolve(vapidKeyToUse) + stub(serviceClass.prototype, 'getPublicVapidKey_').callsFake( + async () => vapidKeyToUse ); stub(BaseController.prototype, 'getNotificationPermission_').callsFake( @@ -211,9 +210,9 @@ describe('Firebase Messaging > *Controller.getToken()', () => { stub( TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(details)); + ).callsFake(async () => details); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const token = await serviceInstance.getToken(); assert.equal(details.fcmToken, token); }); @@ -235,12 +234,11 @@ describe('Firebase Messaging > *Controller.getToken()', () => { () => 'granted' ); - stub( - TokenDetailsModel.prototype, - 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID)); + stub(TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope').callsFake( + async () => EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID + ); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const token = await serviceInstance.getToken(); assert.equal(EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID.fcmToken, token); }); @@ -256,10 +254,9 @@ describe('Firebase Messaging > *Controller.getToken()', () => { () => 'granted' ); - stub( - TokenDetailsModel.prototype, - 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(EXAMPLE_EXPIRED_TOKEN_DETAILS)); + stub(TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope').callsFake( + async () => EXAMPLE_EXPIRED_TOKEN_DETAILS + ); stub(serviceClass.prototype, 'getPublicVapidKey_').callsFake(() => Promise.resolve(DEFAULT_PUBLIC_VAPID_KEY) @@ -283,7 +280,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { 'saveVapidDetails' ).callsFake(async () => {}); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const token = await serviceInstance.getToken(); assert.equal(EXAMPLE_FCM_TOKEN, token); }); @@ -329,14 +326,14 @@ describe('Firebase Messaging > *Controller.getToken()', () => { stub( TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(undefined)); + ).callsFake(async () => undefined); const saveTokenDetailsStub = stub( TokenDetailsModel.prototype, 'saveTokenDetails' ).callsFake(async () => {}); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const token = await serviceInstance.getToken(); assert.equal('example-token', token); @@ -426,9 +423,9 @@ describe('Firebase Messaging > *Controller.getToken()', () => { stub( TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(details)); + ).callsFake(async () => details); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const token = await serviceInstance.getToken(); // make sure we call getToken and retrieve the new token. assert.equal('new-token', token); @@ -470,16 +467,15 @@ describe('Firebase Messaging > *Controller.getToken()', () => { 'saveVapidDetails' ).callsFake(async () => {}); - stub( - TokenDetailsModel.prototype, - 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(EXAMPLE_TOKEN_DETAILS_DEFAULT_VAPID)); + stub(TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope').callsFake( + async () => EXAMPLE_TOKEN_DETAILS_DEFAULT_VAPID + ); stub(serviceClass.prototype, 'getPushSubscription').callsFake(() => Promise.resolve(subscription) ); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); const defaultVAPIDToken = await serviceInstance.getToken(); assert.equal( defaultVAPIDToken, @@ -489,7 +485,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { const GET_TOKEN_RESPONSE = EXAMPLE_TOKEN_DETAILS_CUSTOM_VAPID.fcmToken; // now update the VAPID key. - getPublicVapidKeyStub.callsFake(() => Promise.resolve(CUSTOM_VAPID_KEY)); + getPublicVapidKeyStub.callsFake(async () => CUSTOM_VAPID_KEY); const saveTokenDetailsStub = stub( TokenDetailsModel.prototype, @@ -533,10 +529,9 @@ describe('Firebase Messaging > *Controller.getToken()', () => { () => 'granted' ); - stub( - TokenDetailsModel.prototype, - 'getTokenDetailsFromSWScope' - ).callsFake(() => Promise.resolve(EXAMPLE_EXPIRED_TOKEN_DETAILS)); + stub(TokenDetailsModel.prototype, 'getTokenDetailsFromSWScope').callsFake( + async () => EXAMPLE_EXPIRED_TOKEN_DETAILS + ); stub(serviceClass.prototype, 'getPushSubscription').callsFake(() => Promise.resolve(subscription) @@ -570,7 +565,7 @@ describe('Firebase Messaging > *Controller.getToken()', () => { 'deleteToken' ).callsFake(async () => {}); - const serviceInstance = new serviceClass(app); + const serviceInstance = new serviceClass(firebaseInternalServices); try { await serviceInstance.getToken(); throw new Error('Expected error to be thrown.'); diff --git a/packages/messaging/test/controller-interface.test.ts b/packages/messaging/test/controller-interface.test.ts index cc7cc97a2f8..824bdaa6d3f 100644 --- a/packages/messaging/test/controller-interface.test.ts +++ b/packages/messaging/test/controller-interface.test.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app-types'; import { expect } from 'chai'; import { stub, restore, spy } from 'sinon'; @@ -27,8 +26,9 @@ import { SubscriptionManager } from '../src/models/subscription-manager'; import { TokenDetailsModel } from '../src/models/token-details-model'; import { VapidDetailsModel } from '../src/models/vapid-details-model'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; const controllersToTest = [WindowController, SwController]; @@ -47,10 +47,10 @@ class MockBaseController extends BaseController { } describe('Firebase Messaging > *BaseController', () => { - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; beforeEach(() => { - app = makeFakeApp({ + firebaseInternalServices = makeFakeFirebaseInternalServices({ messagingSenderId: '12345' }); }); @@ -61,7 +61,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('INTERNAL.delete()', () => { it('should call delete()', async () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); const sinonSpy = spy(controller, 'delete'); await controller.INTERNAL.delete(); expect(sinonSpy.callCount).to.equal(1); @@ -70,7 +70,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('requestPermission()', () => { it(`should throw`, async () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { await controller.requestPermission(); @@ -93,7 +93,7 @@ describe('Firebase Messaging > *BaseController', () => { } }); - const controller = new controllerInTest(app); + const controller = new controllerInTest(firebaseInternalServices); return controller .getPushSubscription(reg, DEFAULT_PUBLIC_VAPID_KEY) .then( @@ -113,7 +113,7 @@ describe('Firebase Messaging > *BaseController', () => { getSubscription: async () => exampleSubscription }); - const controller = new controllerInTest(app); + const controller = new controllerInTest(firebaseInternalServices); return controller .getPushSubscription(reg, DEFAULT_PUBLIC_VAPID_KEY) .then(subscription => { @@ -136,7 +136,7 @@ describe('Firebase Messaging > *BaseController', () => { } }); - const controller = new controllerInTest(app); + const controller = new controllerInTest(firebaseInternalServices); return controller .getPushSubscription(reg, DEFAULT_PUBLIC_VAPID_KEY) .then(subscription => { @@ -148,7 +148,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('useServiceWorker()', () => { it(`should throw`, () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { controller.useServiceWorker(null as any); @@ -162,7 +162,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('usePublicVapidKey()', () => { it(`should throw`, () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { controller.usePublicVapidKey(null as any); @@ -176,7 +176,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('onMessage()', () => { it(`should throw`, () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { controller.onMessage(null as any, null as any, null as any); @@ -190,7 +190,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('onTokenRefresh()', () => { it(`should throw`, () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { controller.onTokenRefresh(null as any, null as any, null as any); @@ -204,7 +204,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('setBackgroundMessageHandler()', () => { it(`should throw`, () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); let thrownError; try { controller.setBackgroundMessageHandler(null as any); @@ -219,7 +219,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('getNotificationPermission_', () => { it('should return current permission', () => { stub(Notification as any, 'permission').value('test'); - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); const result = controller.getNotificationPermission_(); expect(result).to.equal('test'); }); @@ -227,7 +227,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('getTokenDetailsModel', () => { it('should return an instance of TokenDetailsModel', () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); const result = controller.getTokenDetailsModel(); expect(result).to.be.instanceof(TokenDetailsModel); }); @@ -235,7 +235,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('getVapidDetailsModel', () => { it('should return an instance of VapidDetailsModel', () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); const result = controller.getVapidDetailsModel(); expect(result).to.be.instanceof(VapidDetailsModel); }); @@ -243,7 +243,7 @@ describe('Firebase Messaging > *BaseController', () => { describe('getIidModel', () => { it('should return an instance of IidModel', () => { - const controller = new MockBaseController(app); + const controller = new MockBaseController(firebaseInternalServices); const result = controller.getSubscriptionManager(); expect(result).to.be.instanceof(SubscriptionManager); }); diff --git a/packages/messaging/test/get-sw-reg.test.ts b/packages/messaging/test/get-sw-reg.test.ts index eebd782e061..c50474536a4 100644 --- a/packages/messaging/test/get-sw-reg.test.ts +++ b/packages/messaging/test/get-sw-reg.test.ts @@ -21,12 +21,12 @@ import { SwController } from '../src/controllers/sw-controller'; import { WindowController } from '../src/controllers/window-controller'; import { ErrorCode } from '../src/models/errors'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; const EXAMPLE_SENDER_ID = '1234567890'; -const app = makeFakeApp({ +const firebaseInternalServices = makeFakeFirebaseInternalServices({ messagingSenderId: EXAMPLE_SENDER_ID }); @@ -62,7 +62,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { mockWindowRegistration(activatedRegistration); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService .getSWRegistration_() .then(registration => { @@ -82,7 +82,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { const fakeReg = makeFakeSWReg(); mockWindowRegistration(fakeReg); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService.getSWRegistration_().then( () => { throw new Error('Expected this error to throw due to no SW.'); @@ -97,7 +97,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { const fakeReg = makeFakeSWReg(); (self as any).registration = fakeReg; - const messagingService = new SwController(app); + const messagingService = new SwController(firebaseInternalServices); return messagingService .getSWRegistration_() .then(registration => { @@ -119,7 +119,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { throw new Error(errorMsg); }); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService.getSWRegistration_().then( () => { throw new Error('Expect getSWRegistration_ to reject.'); @@ -140,7 +140,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { }); mockWindowRegistration(redundantRegistration); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService.getSWRegistration_().then( () => { throw new Error('Should throw error due to redundant SW'); @@ -166,7 +166,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { const slowRedundantRegistration = makeFakeSWReg('installing', swValue); mockWindowRegistration(slowRedundantRegistration); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService.getSWRegistration_().then( () => { throw new Error('Should throw error due to redundant SW'); @@ -192,7 +192,7 @@ describe('Firebase Messaging > *Controller.getSWReg_()', () => { const slowRedundantRegistration = makeFakeSWReg('waiting', swValue); mockWindowRegistration(slowRedundantRegistration); - const messagingService = new WindowController(app); + const messagingService = new WindowController(firebaseInternalServices); return messagingService.getSWRegistration_().then( () => { throw new Error('Should throw error due to redundant SW'); diff --git a/packages/messaging/test/index.test.ts b/packages/messaging/test/index.test.ts index d3146ec1f64..baa33ab8aa3 100644 --- a/packages/messaging/test/index.test.ts +++ b/packages/messaging/test/index.test.ts @@ -19,27 +19,34 @@ import { expect } from 'chai'; import { stub, restore, SinonStub } from 'sinon'; import { FirebaseApp } from '@firebase/app-types'; -import { - _FirebaseNamespace, - FirebaseServiceFactory -} from '@firebase/app-types/private'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { registerMessaging } from '../index'; import { ErrorCode } from '../src/models/errors'; import { SwController } from '../src/controllers/sw-controller'; import { WindowController } from '../src/controllers/window-controller'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { + makeFakeApp, + makeFakeInstallations +} from './testing-utils/make-fake-firebase-services'; +import { + InstanceFactory, + ComponentContainer, + Component, + ComponentType +} from '@firebase/component'; +import { FirebaseMessagingName } from '@firebase/messaging-types'; describe('Firebase Messaging > registerMessaging', () => { - let registerService: SinonStub; + let registerComponent: SinonStub; let fakeFirebase: _FirebaseNamespace; beforeEach(() => { - registerService = stub(); + registerComponent = stub(); fakeFirebase = { - INTERNAL: { registerService } + INTERNAL: { registerComponent } } as any; }); @@ -47,28 +54,40 @@ describe('Firebase Messaging > registerMessaging', () => { restore(); }); - it('calls registerService', () => { + it('calls registerComponent', () => { registerMessaging(fakeFirebase); - expect(registerService.callCount).to.equal(1); + expect(registerComponent.callCount).to.equal(1); }); describe('factoryMethod', () => { - let factoryMethod: FirebaseServiceFactory; + let factoryMethod: InstanceFactory; let fakeApp: FirebaseApp; + let fakeContainer: ComponentContainer; beforeEach(() => { registerMessaging(fakeFirebase); - factoryMethod = registerService.getCall(0).args[1]; + factoryMethod = registerComponent.getCall(0).args[0].instanceFactory; fakeApp = makeFakeApp({ messagingSenderId: '1234567890' }); + fakeContainer = new ComponentContainer('test'); + fakeContainer.addComponent( + new Component('app', () => fakeApp, ComponentType.PUBLIC) + ); + fakeContainer.addComponent( + new Component( + 'installations', + () => makeFakeInstallations(), + ComponentType.PUBLIC + ) + ); }); describe('isSupported', () => { it('is a namespace export', () => { - const namespaceExports = registerService.getCall(0).args[2]; - expect(namespaceExports.isSupported).to.be.a('function'); + const component = registerComponent.getCall(0).args[0]; + expect(component.serviceProps.isSupported).to.be.a('function'); }); }); @@ -84,7 +103,7 @@ describe('Firebase Messaging > registerMessaging', () => { }); it('returns a SwController', () => { - const firebaseService = factoryMethod(fakeApp); + const firebaseService = factoryMethod(fakeContainer); expect(firebaseService).to.be.instanceOf(SwController); }); }); @@ -95,7 +114,7 @@ describe('Firebase Messaging > registerMessaging', () => { stub(window, 'navigator').value({}); try { - factoryMethod(fakeApp); + factoryMethod(fakeContainer); } catch (e) { expect(e.code).to.equal('messaging/' + ErrorCode.UNSUPPORTED_BROWSER); return; @@ -104,7 +123,7 @@ describe('Firebase Messaging > registerMessaging', () => { }); it('returns a WindowController', () => { - const firebaseService = factoryMethod(fakeApp); + const firebaseService = factoryMethod(fakeContainer); expect(firebaseService).to.be.instanceOf(WindowController); }); }); diff --git a/packages/messaging/test/subscription-manager.test.ts b/packages/messaging/test/subscription-manager.test.ts index d4dda7ae118..5c77bcb8a7b 100644 --- a/packages/messaging/test/subscription-manager.test.ts +++ b/packages/messaging/test/subscription-manager.test.ts @@ -23,10 +23,10 @@ import { SubscriptionManager } from '../src/models/subscription-manager'; import { makeFakeSubscription } from './testing-utils/make-fake-subscription'; import { fetchMock } from './testing-utils/mock-fetch'; -import { FirebaseApp } from '@firebase/app-types'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { base64ToArrayBuffer } from '../src/helpers/base64-to-array-buffer'; import { TokenDetails } from '../src/interfaces/token-details'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; // prettier-ignore const appPubKey = new Uint8Array([ @@ -39,13 +39,13 @@ function getDefaultPublicKey(): Uint8Array { } describe('Firebase Messaging > SubscriptionManager', () => { - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; let subscription: PushSubscription; let tokenDetails: TokenDetails; let subscriptionManager: SubscriptionManager; beforeEach(() => { - app = makeFakeApp(); + firebaseInternalServices = makeFakeFirebaseInternalServices(); subscription = makeFakeSubscription(); tokenDetails = { swScope: '/example-scope', @@ -53,7 +53,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { 'BNJxw7sCGkGLOUP2cawBaBXRuWZ3lw_PmQMgreLVVvX_b' + '4emEWVURkCF8fUTHEFe2xrEgTt5ilh5xD94v0pFe_I' ), - fcmSenderId: app.options.messagingSenderId!, + fcmSenderId: firebaseInternalServices.app.options.messagingSenderId!, fcmToken: 'qwerty', endpoint: subscription.endpoint, auth: subscription.getKey('auth')!, @@ -76,7 +76,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { fetchMock.jsonOk(JSON.stringify(mockResponse)) ); const token = await subscriptionManager.getToken( - app, + firebaseInternalServices, subscription, appPubKey ); @@ -92,7 +92,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { fetchMock.jsonOk(JSON.stringify(mockResponse)) ); const token = await subscriptionManager.getToken( - app, + firebaseInternalServices, subscription, getDefaultPublicKey() ); @@ -106,7 +106,11 @@ describe('Firebase Messaging > SubscriptionManager', () => { const errorMsg = 'invalid token'; stub(window, 'fetch').returns(fetchMock.jsonError(400, errorMsg)); try { - await subscriptionManager.getToken(app, subscription, appPubKey); + await subscriptionManager.getToken( + firebaseInternalServices, + subscription, + appPubKey + ); throw new Error('Expected error to be thrown.'); } catch (e) { expect(e.message).to.include(errorMsg); @@ -116,7 +120,11 @@ describe('Firebase Messaging > SubscriptionManager', () => { it('handles fetch errors, HTML response returned', async () => { stub(window, 'fetch').returns(fetchMock.htmlError(400, 'html-response')); try { - await subscriptionManager.getToken(app, subscription, appPubKey); + await subscriptionManager.getToken( + firebaseInternalServices, + subscription, + appPubKey + ); throw new Error('Expected error to be thrown.'); } catch (e) { expect(e.code).to.include(ErrorCode.TOKEN_SUBSCRIBE_FAILED); @@ -129,7 +137,11 @@ describe('Firebase Messaging > SubscriptionManager', () => { fetchMock.jsonOk(JSON.stringify(mockInvalidResponse)) ); try { - await subscriptionManager.getToken(app, subscription, appPubKey); + await subscriptionManager.getToken( + firebaseInternalServices, + subscription, + appPubKey + ); throw new Error('Expected error to be thrown.'); } catch (e) { expect(e.message).to.include( @@ -147,7 +159,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { ); const res = await subscriptionManager.updateToken( tokenDetails, - app, + firebaseInternalServices, subscription, appPubKey ); @@ -162,7 +174,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { ); const res = await subscriptionManager.updateToken( tokenDetails, - app, + firebaseInternalServices, subscription, getDefaultPublicKey() ); @@ -180,7 +192,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { try { await subscriptionManager.updateToken( tokenDetails, - app, + firebaseInternalServices, subscription, appPubKey ); @@ -195,7 +207,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { try { await subscriptionManager.updateToken( tokenDetails, - app, + firebaseInternalServices, subscription, appPubKey ); @@ -211,7 +223,7 @@ describe('Firebase Messaging > SubscriptionManager', () => { try { await subscriptionManager.updateToken( tokenDetails, - app, + firebaseInternalServices, subscription, appPubKey ); @@ -225,7 +237,10 @@ describe('Firebase Messaging > SubscriptionManager', () => { describe('deleteToken', () => { it('deletes on valid request', async () => { stub(window, 'fetch').returns(fetchMock.jsonOk('{}')); - await subscriptionManager.deleteToken(app, tokenDetails); + await subscriptionManager.deleteToken( + firebaseInternalServices, + tokenDetails + ); }); it('handles fetch errors', async () => { @@ -234,7 +249,10 @@ describe('Firebase Messaging > SubscriptionManager', () => { stub(window, 'fetch').returns(fetchMock.jsonError(400, errorMsg)); try { - await subscriptionManager.deleteToken(app, tokenDetails); + await subscriptionManager.deleteToken( + firebaseInternalServices, + tokenDetails + ); throw new Error('Expected error to be thrown.'); } catch (e) { expect(e.code).to.include(ErrorCode.TOKEN_UNSUBSCRIBE_FAILED); @@ -245,7 +263,10 @@ describe('Firebase Messaging > SubscriptionManager', () => { const stubbedFetch = stub(window, 'fetch'); stubbedFetch.returns(fetchMock.htmlError(404, 'html-response')); try { - await subscriptionManager.deleteToken(app, tokenDetails); + await subscriptionManager.deleteToken( + firebaseInternalServices, + tokenDetails + ); throw new Error('Expected error to be thrown.'); } catch (e) { expect(e.code).to.include(ErrorCode.TOKEN_UNSUBSCRIBE_FAILED); diff --git a/packages/messaging/test/sw-controller.test.ts b/packages/messaging/test/sw-controller.test.ts index 5a5bec3ee8e..bed0ecafabc 100644 --- a/packages/messaging/test/sw-controller.test.ts +++ b/packages/messaging/test/sw-controller.test.ts @@ -22,23 +22,20 @@ declare const self: ServiceWorkerGlobalScope; import { expect } from 'chai'; import { stub, restore, spy, SinonSpy } from 'sinon'; - -import { FirebaseApp } from '@firebase/app-types'; import { FirebaseError } from '@firebase/util'; - -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; - import { SwController } from '../src/controllers/sw-controller'; import { base64ToArrayBuffer } from '../src/helpers/base64-to-array-buffer'; import { DEFAULT_PUBLIC_VAPID_KEY } from '../src/models/fcm-details'; import { VapidDetailsModel } from '../src/models/vapid-details-model'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; const VALID_VAPID_KEY = 'BJzVfWqLoALJdgV20MYy6lrj0OfhmE16PI1qLIIYx2ZZL3FoQWJJL8L0rf7rS7tqd92j_3xN3fmejKK5Eb7yMYw'; describe('Firebase Messaging > *SwController', () => { - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; beforeEach(() => { // When trying to stub self.clients self.registration, Sinon complains that @@ -55,7 +52,7 @@ describe('Firebase Messaging > *SwController', () => { (self as any).registration = 'This is a placeholder for sinon to overwrite.'; - app = makeFakeApp({ + firebaseInternalServices = makeFakeFirebaseInternalServices({ messagingSenderId: '12345' }); }); @@ -69,7 +66,7 @@ describe('Firebase Messaging > *SwController', () => { describe('onPush', () => { it('should handle a push event with no data', async () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const waitUntilSpy = spy(); swController.onPush({ waitUntil: waitUntilSpy, @@ -80,7 +77,7 @@ describe('Firebase Messaging > *SwController', () => { }); it('should handle a push event where .json() throws', async () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const waitUntilSpy = spy(); swController.onPush({ waitUntil: waitUntilSpy, @@ -102,7 +99,7 @@ describe('Firebase Messaging > *SwController', () => { async () => true ); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -124,7 +121,7 @@ describe('Firebase Messaging > *SwController', () => { async () => true ); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -148,7 +145,7 @@ describe('Firebase Messaging > *SwController', () => { async () => true ); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.setBackgroundMessageHandler((() => {}) as any); swController.onPush({ waitUntil: waitUntilSpy, @@ -171,7 +168,7 @@ describe('Firebase Messaging > *SwController', () => { ); const showNotificationStub = spy(registration, 'showNotification'); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -209,7 +206,7 @@ describe('Firebase Messaging > *SwController', () => { icon: '/images/test-icon.png' }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -277,7 +274,7 @@ describe('Firebase Messaging > *SwController', () => { ] }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -311,7 +308,7 @@ describe('Firebase Messaging > *SwController', () => { const payloadData = {}; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.setBackgroundMessageHandler(bgMessageHandlerSpy); swController.onPush({ waitUntil: waitUntilSpy, @@ -333,7 +330,7 @@ describe('Firebase Messaging > *SwController', () => { async () => false ); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onPush({ waitUntil: waitUntilSpy, data: { @@ -349,7 +346,7 @@ describe('Firebase Messaging > *SwController', () => { describe('setBackgroundMessageHandler', () => { it('should throw on a non-function input', () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); let thrownError: FirebaseError | undefined; try { swController.setBackgroundMessageHandler('' as any); @@ -378,7 +375,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.hasVisibleClients_(); expect(result).to.equal(false); }); @@ -409,7 +406,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.hasVisibleClients_(); expect(result).to.equal(false); }); @@ -444,7 +441,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.hasVisibleClients_(); expect(result).to.equal(true); }); @@ -467,7 +464,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.hasVisibleClients_(); expect(result).to.equal(false); }); @@ -487,7 +484,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.getWindowClient_('/test-url'); expect(result).to.equal(null); }); @@ -515,7 +512,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.getWindowClient_('/test-url'); expect(result).to.equal(null); }); @@ -547,7 +544,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const result = await swController.getWindowClient_(matchingClient.url); expect(result).to.equal(matchingClient); }); @@ -560,7 +557,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy, stopImmediatePropagation: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); @@ -574,7 +571,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy, stopImmediatePropagation: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); @@ -590,7 +587,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy, stopImmediatePropagation: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); @@ -612,7 +609,7 @@ describe('Firebase Messaging > *SwController', () => { stopImmediatePropagation: spy(), action: 'action1' }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); @@ -631,7 +628,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy, stopImmediatePropagation: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); @@ -653,7 +650,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy, stopImmediatePropagation: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onNotificationClick(event); await event.waitUntil.getCall(0).args[0]; @@ -712,7 +709,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); stub(swController, 'getWindowClient_').callsFake(async () => null); @@ -739,7 +736,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); stub(swController, 'getWindowClient_').callsFake(async () => null); const attemptToMessageClientStub = stub( @@ -782,7 +779,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); stub(swController, 'getWindowClient_').callsFake( async () => fakeWindowClient as WindowClient @@ -818,14 +815,14 @@ describe('Firebase Messaging > *SwController', () => { describe('getNotificationData_', () => { it('should return nothing for no payload', () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); expect(swController.getNotificationData_(undefined as any)).to.equal( undefined ); }); it('adds message payload to data.FCM_MSG without replacing user defined data', () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const msgPayload = { notification: { title: 'Hello World', @@ -852,7 +849,7 @@ describe('Firebase Messaging > *SwController', () => { describe('attemptToMessageClient_', () => { it('should reject when no window client provided', () => { - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); return swController.attemptToMessageClient_(null as any, {} as any).then( () => { throw new Error('Expected error to be thrown'); @@ -869,7 +866,7 @@ describe('Firebase Messaging > *SwController', () => { const client: any = { postMessage: spy() }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); await swController.attemptToMessageClient_(client, msg); expect(client.postMessage.callCount).to.equal(1); expect(client.postMessage.getCall(0).args[0]).to.equal(msg); @@ -884,7 +881,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const payload = {}; @@ -904,7 +901,7 @@ describe('Firebase Messaging > *SwController', () => { }; stub(self, 'clients').value(clients); - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); const attemptToMessageClientStub = stub( swController, 'attemptToMessageClient_' @@ -943,7 +940,7 @@ describe('Firebase Messaging > *SwController', () => { const onPushStub = stub(SwController.prototype, 'onPush'); const pushEvent = new Event('push'); - new SwController(app); + new SwController(firebaseInternalServices); expect(listeners['push']).to.exist; listeners['push'](pushEvent); @@ -960,7 +957,7 @@ describe('Firebase Messaging > *SwController', () => { const onSubChangeStub = stub(SwController.prototype, 'onSubChange'); const pushEvent = new Event('pushsubscriptionchange'); - new SwController(app); + new SwController(firebaseInternalServices); expect(listeners['pushsubscriptionchange']).to.exist; listeners['pushsubscriptionchange'](pushEvent); @@ -986,7 +983,7 @@ describe('Firebase Messaging > *SwController', () => { ); const pushEvent = new Event('notificationclick'); - new SwController(app); + new SwController(firebaseInternalServices); expect(listeners['notificationclick']).to.exist; listeners['notificationclick'](pushEvent); @@ -1013,7 +1010,7 @@ describe('Firebase Messaging > *SwController', () => { waitUntil: waitUntilSpy }; - const swController = new SwController(app); + const swController = new SwController(firebaseInternalServices); swController.onSubChange(event); let error: FirebaseError | undefined; @@ -1032,7 +1029,7 @@ describe('Firebase Messaging > *SwController', () => { it('should return the default key by default', async () => { const registration = makeFakeSWReg(); stub(self, 'registration').value(registration); - const controller = new SwController(app); + const controller = new SwController(firebaseInternalServices); stub(VapidDetailsModel.prototype, 'getVapidFromSWScope').callsFake( async () => undefined ); @@ -1043,7 +1040,7 @@ describe('Firebase Messaging > *SwController', () => { it('should return the default key', async () => { const registration = makeFakeSWReg(); stub(self, 'registration').value(registration); - const controller = new SwController(app); + const controller = new SwController(firebaseInternalServices); stub(VapidDetailsModel.prototype, 'getVapidFromSWScope').callsFake( async () => DEFAULT_PUBLIC_VAPID_KEY ); @@ -1054,7 +1051,7 @@ describe('Firebase Messaging > *SwController', () => { it('should return the custom key if set', async () => { const registration = makeFakeSWReg(); stub(self, 'registration').value(registration); - const controller = new SwController(app); + const controller = new SwController(firebaseInternalServices); const vapidKeyInUse = base64ToArrayBuffer(VALID_VAPID_KEY); stub(VapidDetailsModel.prototype, 'getVapidFromSWScope').callsFake( async () => vapidKeyInUse diff --git a/packages/messaging/test/testing-utils/make-fake-app.ts b/packages/messaging/test/testing-utils/make-fake-firebase-services.ts similarity index 54% rename from packages/messaging/test/testing-utils/make-fake-app.ts rename to packages/messaging/test/testing-utils/make-fake-firebase-services.ts index 5fd17133a90..ecfd9b4f2e6 100644 --- a/packages/messaging/test/testing-utils/make-fake-app.ts +++ b/packages/messaging/test/testing-utils/make-fake-firebase-services.ts @@ -16,6 +16,20 @@ */ import { FirebaseApp, FirebaseOptions } from '@firebase/app-types'; +import { FirebaseInstallations } from '@firebase/installations-types'; +import { FirebaseAnalyticsInternalName } from '@firebase/analytics-interop-types'; +import { Provider, ComponentContainer } from '@firebase/component'; +import { FirebaseInternalServices } from '../../src/interfaces/internal-services'; + +export function makeFakeFirebaseInternalServices( + options: FirebaseOptions = {} +): FirebaseInternalServices { + return { + app: makeFakeApp(options), + installations: makeFakeInstallations(), + analyticsProvider: makeFakeAnalyticsProvider() + }; +} export function makeFakeApp(options: FirebaseOptions = {}): FirebaseApp { options = { @@ -34,12 +48,23 @@ export function makeFakeApp(options: FirebaseOptions = {}): FirebaseApp { automaticDataCollectionEnabled: true, delete: async () => {}, messaging: null as any, - installations() { - return { - getId: () => Promise.resolve('FID'), - getToken: () => Promise.resolve('authToken'), - delete: () => Promise.resolve() - }; - } + installations: null as any + }; +} + +export function makeFakeInstallations(): FirebaseInstallations { + return { + getId: async () => 'FID', + getToken: async () => 'authToken', + delete: async () => undefined }; } + +export function makeFakeAnalyticsProvider(): Provider< + FirebaseAnalyticsInternalName +> { + return new Provider( + 'analytics-internal', + new ComponentContainer('test') + ); +} diff --git a/packages/messaging/test/token-details-model.test.ts b/packages/messaging/test/token-details-model.test.ts index e2f7bb3a649..19356552d4b 100644 --- a/packages/messaging/test/token-details-model.test.ts +++ b/packages/messaging/test/token-details-model.test.ts @@ -17,24 +17,22 @@ import { assert } from 'chai'; import { useFakeTimers } from 'sinon'; - import { arrayBufferToBase64 } from '../src/helpers/array-buffer-to-base64'; import { base64ToArrayBuffer } from '../src/helpers/base64-to-array-buffer'; import { TokenDetails } from '../src/interfaces/token-details'; import { ErrorCode } from '../src/models/errors'; import { TokenDetailsModel } from '../src/models/token-details-model'; - import { deleteDatabase } from './testing-utils/db-helper'; import { compareDetails } from './testing-utils/detail-comparator'; import { makeFakeSubscription } from './testing-utils/make-fake-subscription'; -import { FirebaseApp } from '@firebase/app-types'; -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; +import { FirebaseInternalServices } from '../src/interfaces/internal-services'; const BAD_INPUTS: any[] = ['', [], {}, true, null, 123]; describe('Firebase Messaging > TokenDetailsModel', () => { let clock: sinon.SinonFakeTimers; - let app: FirebaseApp; + let firebaseInternalServices: FirebaseInternalServices; let tokenDetailsModel: TokenDetailsModel; let exampleInput: TokenDetails; let fakeSubscription: PushSubscription; @@ -42,8 +40,8 @@ describe('Firebase Messaging > TokenDetailsModel', () => { beforeEach(() => { clock = useFakeTimers(); - app = makeFakeApp(); - tokenDetailsModel = new TokenDetailsModel(app); + firebaseInternalServices = makeFakeFirebaseInternalServices(); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); fakeSubscription = makeFakeSubscription(); exampleInput = { @@ -74,7 +72,9 @@ describe('Firebase Messaging > TokenDetailsModel', () => { protected readonly dbVersion = 2; } - const oldDBTokenDetailsModel = new OldDBTokenDetailsModel(app); + const oldDBTokenDetailsModel = new OldDBTokenDetailsModel( + firebaseInternalServices + ); // Old (v2) version of exampleInput // vapidKey, auth and p256dh are strings, @@ -109,7 +109,9 @@ describe('Firebase Messaging > TokenDetailsModel', () => { protected readonly dbVersion = 3; } - const oldDBTokenDetailsModel = new OldDBTokenDetailsModel(app); + const oldDBTokenDetailsModel = new OldDBTokenDetailsModel( + firebaseInternalServices + ); // Old (v3) version of exampleInput // fcmPushSet exists @@ -142,7 +144,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { describe('saveToken', () => { it('should throw on bad input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.swScope = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -159,7 +161,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad vapid key input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.vapidKey = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -176,7 +178,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad endpoint input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.endpoint = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -193,7 +195,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad auth input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.auth = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -210,7 +212,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad p256dh input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.p256dh = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -227,7 +229,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad send id input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.fcmSenderId = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -244,7 +246,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { it('should throw on bad token input', () => { const promises = BAD_INPUTS.map(badInput => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); exampleInput.fcmToken = badInput; return tokenDetailsModel.saveTokenDetails(exampleInput).then( () => { @@ -260,7 +262,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { }); it('should save valid details', () => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); return tokenDetailsModel.saveTokenDetails(exampleInput); }); }); @@ -331,7 +333,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { describe('deleteToken', () => { it('should handle no input', () => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); return tokenDetailsModel.deleteToken(undefined as any).then( () => { throw new Error('Expected this to throw an error due to no token'); @@ -343,7 +345,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { }); it('should handle empty string', () => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); return tokenDetailsModel.deleteToken('').then( () => { throw new Error('Expected this to throw an error due to no token'); @@ -355,7 +357,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { }); it('should delete current token', () => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); return tokenDetailsModel .saveTokenDetails(exampleInput) .then(() => { @@ -373,7 +375,7 @@ describe('Firebase Messaging > TokenDetailsModel', () => { }); it('should handle deleting a non-existant token', () => { - tokenDetailsModel = new TokenDetailsModel(app); + tokenDetailsModel = new TokenDetailsModel(firebaseInternalServices); return tokenDetailsModel.deleteToken('bad-token').then( () => { throw new Error('Expected this delete to throw and error.'); diff --git a/packages/messaging/test/window-controller.test.ts b/packages/messaging/test/window-controller.test.ts index a3831131446..f3dcde14870 100644 --- a/packages/messaging/test/window-controller.test.ts +++ b/packages/messaging/test/window-controller.test.ts @@ -16,10 +16,8 @@ */ import { expect } from 'chai'; import { stub, restore, spy } from 'sinon'; - -import { makeFakeApp } from './testing-utils/make-fake-app'; +import { makeFakeFirebaseInternalServices } from './testing-utils/make-fake-firebase-services'; import { makeFakeSWReg } from './testing-utils/make-fake-sw-reg'; - import { WindowController } from '../src/controllers/window-controller'; import { base64ToArrayBuffer } from '../src/helpers/base64-to-array-buffer'; import { DEFAULT_PUBLIC_VAPID_KEY } from '../src/models/fcm-details'; @@ -29,7 +27,7 @@ const VALID_VAPID_KEY = 'BJzVfWqLoALJdgV20MYy6lrj0OfhmE16PI1qLIIYx2ZZL3FoQWJJL8L0rf7rS7tqd92j_3xN3fmejKK5Eb7yMYw'; describe('Firebase Messaging > *WindowController', () => { - const app = makeFakeApp({ + const fakeFirebaseServices = makeFakeFirebaseInternalServices({ messagingSenderId: '12345' }); @@ -49,7 +47,7 @@ describe('Firebase Messaging > *WindowController', () => { it('should resolve if the permission is already granted', () => { stub(Notification as any, 'permission').value('granted'); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); return controller.requestPermission(); }); @@ -59,7 +57,7 @@ describe('Firebase Messaging > *WindowController', () => { Promise.resolve('denied') ); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); return controller.requestPermission().then( () => { throw new Error('Expected an error.'); @@ -76,7 +74,7 @@ describe('Firebase Messaging > *WindowController', () => { Promise.resolve('default') ); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); return controller.requestPermission().then( () => { throw new Error('Expected an error.'); @@ -93,14 +91,14 @@ describe('Firebase Messaging > *WindowController', () => { Promise.resolve('granted') ); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); return controller.requestPermission(); }); }); describe('useServiceWorker()', () => { it(`should throw on invalid input`, () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); let thrownError; try { controller.useServiceWorker(null as any); @@ -113,7 +111,7 @@ describe('Firebase Messaging > *WindowController', () => { it(`should only be callable once`, () => { const registration = makeFakeSWReg(); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.useServiceWorker(registration); let thrownError; @@ -134,7 +132,7 @@ describe('Firebase Messaging > *WindowController', () => { const errFunc = (): void => {}; const compFunc = (): void => {}; - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); const onMessageStub = stub(controller as any, 'onMessage'); controller.onMessage(nextFunc, errFunc, compFunc); @@ -151,7 +149,7 @@ describe('Firebase Messaging > *WindowController', () => { const errFunc = (): void => {}; const compFunc = (): void => {}; - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); const onTokenRefreshStub = stub(controller as any, 'onTokenRefresh'); controller.onTokenRefresh(nextFunc, errFunc, compFunc); @@ -164,7 +162,7 @@ describe('Firebase Messaging > *WindowController', () => { describe('usePublicVapidKey()', () => { it('should throw an error when passing in an invalid value', () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); let thrownError; try { @@ -177,7 +175,7 @@ describe('Firebase Messaging > *WindowController', () => { }); it('should throw an error when called twice', () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.usePublicVapidKey(VALID_VAPID_KEY); let thrownError; @@ -193,7 +191,7 @@ describe('Firebase Messaging > *WindowController', () => { }); it('should throw when decrypting to invalid value', () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); let thrownError; try { @@ -212,14 +210,14 @@ describe('Firebase Messaging > *WindowController', () => { describe('getPublicVapidKey_()', () => { it('should return the default key by default', () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); return controller.getPublicVapidKey_().then(pubKey => { expect(pubKey).to.equal(DEFAULT_PUBLIC_VAPID_KEY); }); }); it('should return the custom key if set', () => { - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.usePublicVapidKey(VALID_VAPID_KEY); return controller.getPublicVapidKey_().then(pubKey => { expect(pubKey).deep.equal(base64ToArrayBuffer(VALID_VAPID_KEY)); @@ -234,7 +232,7 @@ describe('Firebase Messaging > *WindowController', () => { addEventListener: sinonSpy }); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.setupSWMessageListener_(); expect(sinonSpy.args[0][0]).to.equal('message'); @@ -248,7 +246,7 @@ describe('Firebase Messaging > *WindowController', () => { addEventListener: sinonSpy }); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.onMessage(onMessageSpy, null as any, null as any); controller.setupSWMessageListener_(); @@ -270,7 +268,7 @@ describe('Firebase Messaging > *WindowController', () => { addEventListener: messageCallbackSpy }); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); controller.setupSWMessageListener_(); const callback = messageCallbackSpy.args[0][1]; @@ -293,7 +291,7 @@ describe('Firebase Messaging > *WindowController', () => { addEventListener: messageCallbackSpy }); - const controller = new WindowController(app); + const controller = new WindowController(fakeFirebaseServices); // The API for the observables means it's async and so we kind have to // hope that everything is set up after a task skip @@ -339,7 +337,7 @@ describe('Firebase Messaging > *WindowController', () => { const fakeReg = makeFakeSWReg('installing', swValue); - const messagingService = new WindowController(app); + const messagingService = new WindowController(fakeFirebaseServices); const waitPromise = messagingService.waitForRegistrationToActivate_( fakeReg ); diff --git a/packages/performance-types/index.d.ts b/packages/performance-types/index.d.ts index c39c7eb9881..9197a734e91 100644 --- a/packages/performance-types/index.d.ts +++ b/packages/performance-types/index.d.ts @@ -109,3 +109,9 @@ export interface PerformanceTrace { */ getAttributes(): { [key: string]: string }; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'performance': FirebasePerformance; + } +} diff --git a/packages/performance/index.ts b/packages/performance/index.ts index c009313e1d9..b6bfecf9abc 100644 --- a/packages/performance/index.ts +++ b/packages/performance/index.ts @@ -16,22 +16,23 @@ */ import firebase from '@firebase/app'; +import '@firebase/installations'; import { FirebaseApp, FirebaseNamespace } from '@firebase/app-types'; -import { - _FirebaseNamespace, - FirebaseServiceFactory -} from '@firebase/app-types/private'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { PerformanceController } from './src/controllers/perf'; import { setupApi } from './src/services/api_service'; import { SettingsService } from './src/services/settings_service'; import { ERROR_FACTORY, ErrorCode } from './src/utils/errors'; import { FirebasePerformance } from '@firebase/performance-types'; +import { Component, ComponentType } from '@firebase/component'; +import { FirebaseInstallations } from '@firebase/installations-types'; const DEFAULT_ENTRY_NAME = '[DEFAULT]'; export function registerPerformance(instance: FirebaseNamespace): void { - const factoryMethod: FirebaseServiceFactory = ( - app: FirebaseApp + const factoryMethod = ( + app: FirebaseApp, + installations: FirebaseInstallations ): PerformanceController => { if (app.name !== DEFAULT_ENTRY_NAME) { throw ERROR_FACTORY.create(ErrorCode.FB_NOT_DEFAULT); @@ -41,15 +42,27 @@ export function registerPerformance(instance: FirebaseNamespace): void { } setupApi(window); SettingsService.getInstance().firebaseAppInstance = app; + SettingsService.getInstance().installationsService = installations; return new PerformanceController(app); }; // Register performance with firebase-app. - const namespaceExports = {}; - (instance as _FirebaseNamespace).INTERNAL.registerService( - 'performance', - factoryMethod, - namespaceExports + (instance as _FirebaseNamespace).INTERNAL.registerComponent( + new Component( + 'performance', + container => { + /* Dependencies */ + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + // The following call will always succeed because perf has `import '@firebase/installations'` + const installations = container + .getProvider('installations') + .getImmediate(); + + return factoryMethod(app, installations); + }, + ComponentType.PUBLIC + ) ); } diff --git a/packages/performance/package.json b/packages/performance/package.json index 0b9ce576ac4..42f03192f51 100644 --- a/packages/performance/package.json +++ b/packages/performance/package.json @@ -30,6 +30,7 @@ "@firebase/installations": "0.3.6", "@firebase/util": "0.2.34", "@firebase/performance-types": "0.0.6", + "@firebase/component": "0.1.0", "tslib": "1.10.0" }, "license": "Apache-2.0", diff --git a/packages/performance/src/services/iid_service.test.ts b/packages/performance/src/services/iid_service.test.ts index 36bc6e2afbf..cd1444e1b09 100644 --- a/packages/performance/src/services/iid_service.test.ts +++ b/packages/performance/src/services/iid_service.test.ts @@ -24,18 +24,21 @@ import { getAuthenticationToken, getAuthTokenPromise } from './iid_service'; -import { FirebaseApp } from '@firebase/app-types'; import '../../test/setup'; +import { FirebaseInstallations } from '@firebase/installations-types'; describe('Firebase Perofmrance > iid_service', () => { const IID = 'fid'; const AUTH_TOKEN = 'authToken'; - const getId = stub().resolves(IID); - const getToken = stub().resolves(AUTH_TOKEN); - SettingsService.prototype.firebaseAppInstance = ({ - installations: () => ({ getId, getToken }) - } as unknown) as FirebaseApp; + before(() => { + const getId = stub().resolves(IID); + const getToken = stub().resolves(AUTH_TOKEN); + SettingsService.prototype.installationsService = ({ + getId, + getToken + } as unknown) as FirebaseInstallations; + }); describe('getIidPromise', () => { it('provides iid', async () => { diff --git a/packages/performance/src/services/iid_service.ts b/packages/performance/src/services/iid_service.ts index c0d3b14edac..5750de0e8aa 100644 --- a/packages/performance/src/services/iid_service.ts +++ b/packages/performance/src/services/iid_service.ts @@ -14,17 +14,13 @@ * See the License for the specific language governing permissions and * limitations under the License. */ - -import '@firebase/installations'; import { SettingsService } from './settings_service'; let iid: string | undefined; let authToken: string | undefined; export function getIidPromise(): Promise { - const iidPromise = SettingsService.getInstance() - .firebaseAppInstance.installations() - .getId(); + const iidPromise = SettingsService.getInstance().installationsService.getId(); // eslint-disable-next-line @typescript-eslint/no-floating-promises iidPromise.then((iidVal: string) => { iid = iidVal; @@ -38,9 +34,7 @@ export function getIid(): string | undefined { } export function getAuthTokenPromise(): Promise { - const authTokenPromise = SettingsService.getInstance() - .firebaseAppInstance.installations() - .getToken(); + const authTokenPromise = SettingsService.getInstance().installationsService.getToken(); // eslint-disable-next-line @typescript-eslint/no-floating-promises authTokenPromise.then((authTokenVal: string) => { authToken = authTokenVal; diff --git a/packages/performance/src/services/settings_service.ts b/packages/performance/src/services/settings_service.ts index 4e0fafa9572..d1f48df7ef9 100644 --- a/packages/performance/src/services/settings_service.ts +++ b/packages/performance/src/services/settings_service.ts @@ -17,6 +17,7 @@ import { FirebaseApp } from '@firebase/app-types'; import { ERROR_FACTORY, ErrorCode } from '../utils/errors'; +import { FirebaseInstallations } from '@firebase/installations-types'; let settingsServiceInstance: SettingsService | undefined; @@ -46,6 +47,8 @@ export class SettingsService { firebaseAppInstance!: FirebaseApp; + installationsService!: FirebaseInstallations; + getAppId(): string { const appId = this.firebaseAppInstance && diff --git a/packages/remote-config-types/index.d.ts b/packages/remote-config-types/index.d.ts index fcbe2341139..f46585d899f 100644 --- a/packages/remote-config-types/index.d.ts +++ b/packages/remote-config-types/index.d.ts @@ -172,3 +172,9 @@ export type FetchStatus = 'no-fetch-yet' | 'success' | 'failure' | 'throttle'; * Defines levels of Remote Config logging. */ export type LogLevel = 'debug' | 'error' | 'silent'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'remoteConfig': RemoteConfig; + } +} diff --git a/packages/remote-config/index.ts b/packages/remote-config/index.ts index b3456eb031a..09c8454ae1e 100644 --- a/packages/remote-config/index.ts +++ b/packages/remote-config/index.ts @@ -16,8 +16,8 @@ */ import firebase from '@firebase/app'; -import { _FirebaseNamespace } from '@firebase/app-types/private'; import '@firebase/installations'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { RemoteConfig as RemoteConfigType } from '@firebase/remote-config-types'; import { CachingClient } from './src/client/caching_client'; import { RestClient } from './src/client/rest_client'; @@ -28,6 +28,11 @@ import { ERROR_FACTORY, ErrorCode } from './src/errors'; import { RetryingClient } from './src/client/retrying_client'; import { Logger, LogLevel as FirebaseLogLevel } from '@firebase/logger'; import { name as packageName } from './package.json'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; // Facilitates debugging by enabling settings changes without rebuilding asset. // Note these debug options are not part of a documented, supported API and can change at any time. @@ -42,71 +47,82 @@ declare global { export function registerRemoteConfig( firebaseInstance: _FirebaseNamespace ): void { - firebaseInstance.INTERNAL.registerService( - 'remoteConfig', - (app, _, namespace) => { - // Guards against the SDK being used in non-browser environments. - if (typeof window === 'undefined') { - throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_WINDOW); - } + firebaseInstance.INTERNAL.registerComponent( + new Component( + 'remoteConfig', + remoteConfigFactory, + ComponentType.PUBLIC + ).setMultipleInstances(true) + ); - // Normalizes optional inputs. - const { projectId, apiKey, appId } = app.options; - if (!projectId) { - throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_PROJECT_ID); - } - if (!apiKey) { - throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_API_KEY); - } - if (!appId) { - throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_APP_ID); - } - namespace = namespace || 'firebase'; + function remoteConfigFactory( + container: ComponentContainer, + namespace?: string + ): RemoteConfig { + /* Dependencies */ + // getImmediate for FirebaseApp will always succeed + const app = container.getProvider('app').getImmediate(); + // The following call will always succeed because rc has `import '@firebase/installations'` + const installations = container.getProvider('installations').getImmediate(); - const storage = new Storage(appId, app.name, namespace); - const storageCache = new StorageCache(storage); + // Guards against the SDK being used in non-browser environments. + if (typeof window === 'undefined') { + throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_WINDOW); + } - const logger = new Logger(packageName); + // Normalizes optional inputs. + const { projectId, apiKey, appId } = app.options; + if (!projectId) { + throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_PROJECT_ID); + } + if (!apiKey) { + throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_API_KEY); + } + if (!appId) { + throw ERROR_FACTORY.create(ErrorCode.REGISTRATION_APP_ID); + } + namespace = namespace || 'firebase'; - // Sets ERROR as the default log level. - // See RemoteConfig#setLogLevel for corresponding normalization to ERROR log level. - logger.logLevel = FirebaseLogLevel.ERROR; + const storage = new Storage(appId, app.name, namespace); + const storageCache = new StorageCache(storage); - const restClient = new RestClient( - app.installations(), - // Uses the JS SDK version, by which the RC package version can be deduced, if necessary. - firebaseInstance.SDK_VERSION, - namespace, - projectId, - apiKey, - appId - ); - const retryingClient = new RetryingClient(restClient, storage); - const cachingClient = new CachingClient( - retryingClient, - storage, - storageCache, - logger - ); + const logger = new Logger(packageName); - const remoteConfigInstance = new RemoteConfig( - app, - cachingClient, - storageCache, - storage, - logger - ); + // Sets ERROR as the default log level. + // See RemoteConfig#setLogLevel for corresponding normalization to ERROR log level. + logger.logLevel = FirebaseLogLevel.ERROR; - // Starts warming cache. - // eslint-disable-next-line @typescript-eslint/no-floating-promises - remoteConfigInstance.ensureInitialized(); + const restClient = new RestClient( + installations, + // Uses the JS SDK version, by which the RC package version can be deduced, if necessary. + firebaseInstance.SDK_VERSION, + namespace, + projectId, + apiKey, + appId + ); + const retryingClient = new RetryingClient(restClient, storage); + const cachingClient = new CachingClient( + retryingClient, + storage, + storageCache, + logger + ); - return remoteConfigInstance; - }, - undefined, - undefined, - true /* allowMultipleInstances */ - ); + const remoteConfigInstance = new RemoteConfig( + app, + cachingClient, + storageCache, + storage, + logger + ); + + // Starts warming cache. + // eslint-disable-next-line @typescript-eslint/no-floating-promises + remoteConfigInstance.ensureInitialized(); + + return remoteConfigInstance; + } } registerRemoteConfig(firebase as _FirebaseNamespace); diff --git a/packages/remote-config/package.json b/packages/remote-config/package.json index 4ba277ea68b..11240192b4d 100644 --- a/packages/remote-config/package.json +++ b/packages/remote-config/package.json @@ -30,6 +30,7 @@ "@firebase/logger": "0.1.31", "@firebase/remote-config-types": "0.1.3", "@firebase/util": "0.2.34", + "@firebase/component": "0.1.0", "tslib": "1.10.0" }, "license": "Apache-2.0", diff --git a/packages/remote-config/test/client/rest_client.test.ts b/packages/remote-config/test/client/rest_client.test.ts index 6f48dbd4ce3..748009d0719 100644 --- a/packages/remote-config/test/client/rest_client.test.ts +++ b/packages/remote-config/test/client/rest_client.test.ts @@ -54,7 +54,10 @@ describe('RestClient', () => { }); describe('fetch', () => { - let fetchStub: sinon.SinonStub; + let fetchStub: sinon.SinonStub< + [RequestInfo, RequestInit?], + Promise + >; beforeEach(() => { fetchStub = sinon @@ -185,7 +188,7 @@ describe('RestClient', () => { Promise.resolve({ status: 200, headers: new Headers({ ETag: 'etag' }), - json: () => Promise.resolve({ state: 'INSTANCE_STATE_UNSPECIFIED' }) + json: async () => ({ state: 'INSTANCE_STATE_UNSPECIFIED' }) } as Response) ); @@ -205,7 +208,7 @@ describe('RestClient', () => { Promise.resolve({ status: 200, headers: new Headers({ ETag: 'etag' }), - json: () => Promise.resolve({ state: 'NO_CHANGE' }) + json: async () => ({ state: 'NO_CHANGE' }) } as Response) ); @@ -224,7 +227,7 @@ describe('RestClient', () => { Promise.resolve({ status: 200, headers: new Headers({ ETag: 'etag' }), - json: () => Promise.resolve({ state }) + json: async () => ({ state }) } as Response) ); diff --git a/packages/remote-config/test/remote_config.test.ts b/packages/remote-config/test/remote_config.test.ts index 13a87e0fc8a..d31eb2634f0 100644 --- a/packages/remote-config/test/remote_config.test.ts +++ b/packages/remote-config/test/remote_config.test.ts @@ -423,7 +423,11 @@ describe('RemoteConfig', () => { }); describe('fetch', () => { - let timeoutStub: sinon.SinonStub; + let timeoutStub: sinon.SinonStub<[ + (...args: any[]) => void, + number, + ...any[] + ]>; beforeEach(() => { client.fetch = sinon .stub() diff --git a/packages/rxfire/test/database.test.ts b/packages/rxfire/test/database.test.ts index 08b14536426..feecc687205 100644 --- a/packages/rxfire/test/database.test.ts +++ b/packages/rxfire/test/database.test.ts @@ -92,6 +92,7 @@ describe('RxFire Database', () => { */ beforeEach(() => { app = initializeApp({ + apiKey: TEST_PROJECT.apiKey, projectId: TEST_PROJECT.projectId, databaseURL: TEST_PROJECT.databaseURL }); diff --git a/packages/storage-types/index.d.ts b/packages/storage-types/index.d.ts index fcee506c5cb..b109be99f79 100644 --- a/packages/storage-types/index.d.ts +++ b/packages/storage-types/index.d.ts @@ -126,3 +126,9 @@ export class FirebaseStorage { setMaxOperationRetryTime(time: number): void; setMaxUploadRetryTime(time: number): void; } + +declare module '@firebase/component' { + interface NameServiceMapping { + 'storage': FirebaseStorage; + } +} diff --git a/packages/storage/index.ts b/packages/storage/index.ts index 66f2d170069..7c8d2659daf 100644 --- a/packages/storage/index.ts +++ b/packages/storage/index.ts @@ -16,11 +16,7 @@ */ import firebase from '@firebase/app'; -import { FirebaseApp } from '@firebase/app-types'; -import { - FirebaseServiceFactory, - _FirebaseNamespace -} from '@firebase/app-types/private'; +import { _FirebaseNamespace } from '@firebase/app-types/private'; import { StringFormat } from './src/implementation/string'; import { TaskEvent, TaskState } from './src/implementation/taskenums'; @@ -28,6 +24,11 @@ import { XhrIoPool } from './src/implementation/xhriopool'; import { Reference } from './src/reference'; import { Service } from './src/service'; import * as types from '@firebase/storage-types'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; /** * Type constant for Firebase Storage. @@ -35,12 +36,16 @@ import * as types from '@firebase/storage-types'; const STORAGE_TYPE = 'storage'; function factory( - app: FirebaseApp, - unused: unknown, + container: ComponentContainer, url?: string ): types.FirebaseStorage { + // Dependencies + const app = container.getProvider('app').getImmediate(); + const authProvider = container.getProvider('auth-internal'); + return (new Service( app, + authProvider, new XhrIoPool(), url ) as unknown) as types.FirebaseStorage; @@ -55,13 +60,10 @@ export function registerStorage(instance: _FirebaseNamespace): void { Storage: Service, Reference }; - instance.INTERNAL.registerService( - STORAGE_TYPE, - factory as FirebaseServiceFactory, - namespaceExports, - undefined, - // Allow multiple storage instances per app. - true + instance.INTERNAL.registerComponent( + new Component(STORAGE_TYPE, factory, ComponentType.PUBLIC) + .setServiceProps(namespaceExports) + .setMultipleInstances(true) ); } diff --git a/packages/storage/package.json b/packages/storage/package.json index 95d58d4eb61..b213ea7a989 100644 --- a/packages/storage/package.json +++ b/packages/storage/package.json @@ -22,6 +22,7 @@ "dependencies": { "@firebase/storage-types": "0.3.6", "@firebase/util": "0.2.34", + "@firebase/component": "0.1.0", "tslib": "1.10.0" }, "peerDependencies": { diff --git a/packages/storage/src/implementation/authwrapper.ts b/packages/storage/src/implementation/authwrapper.ts index 2cb8662c298..2caa98bb2c5 100644 --- a/packages/storage/src/implementation/authwrapper.ts +++ b/packages/storage/src/implementation/authwrapper.ts @@ -31,6 +31,8 @@ import { _FirebaseApp, FirebaseAuthTokenData } from '@firebase/app-types/private'; +import { Provider } from '@firebase/component'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; /** * @param app If null, getAuthToken always resolves with null. @@ -40,6 +42,7 @@ import { */ export class AuthWrapper { private app_: FirebaseApp | null; + private authProvider_: Provider; private bucket_: string | null = null; private storageRefMaker_: (p1: AuthWrapper, p2: Location) => Reference; @@ -53,6 +56,7 @@ export class AuthWrapper { constructor( app: FirebaseApp | null, + authProvider: Provider, maker: (p1: AuthWrapper, p2: Location) => Reference, requestMaker: requestMaker, service: Service, @@ -65,6 +69,7 @@ export class AuthWrapper { this.bucket_ = AuthWrapper.extractBucket_(options); } } + this.authProvider_ = authProvider; this.storageRefMaker_ = maker; this.requestMaker_ = requestMaker; this.pool_ = pool; @@ -84,14 +89,9 @@ export class AuthWrapper { } getAuthToken(): Promise { - // TODO(andysoto): remove ifDef checks after firebase-app implements stubs - // (b/28673818). - if ( - this.app_ !== null && - type.isDef((this.app_ as _FirebaseApp).INTERNAL) && - type.isDef((this.app_ as _FirebaseApp).INTERNAL.getToken) - ) { - return (this.app_ as _FirebaseApp).INTERNAL.getToken().then( + const auth = this.authProvider_.getImmediate({ optional: true }); + if (auth) { + return auth.getToken().then( (response: FirebaseAuthTokenData | null): string | null => { if (response !== null) { return response.accessToken; diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 0c148dd0a97..88b317fdf2c 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -22,6 +22,8 @@ import { Location } from './implementation/location'; import * as RequestExports from './implementation/request'; import { XhrIoPool } from './implementation/xhriopool'; import { Reference } from './reference'; +import { Provider } from '@firebase/component'; +import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; /** * A service that provides firebaseStorage.Reference instances. @@ -35,12 +37,18 @@ export class Service { private bucket_: Location | null = null; private internals_: ServiceInternals; - constructor(app: FirebaseApp, pool: XhrIoPool, url?: string) { + constructor( + app: FirebaseApp, + authProvider: Provider, + pool: XhrIoPool, + url?: string + ) { function maker(authWrapper: AuthWrapper, loc: Location): Reference { return new Reference(authWrapper, loc); } this.authWrapper_ = new AuthWrapper( app, + authProvider, maker, RequestExports.makeRequest, this, diff --git a/packages/storage/test/reference.test.ts b/packages/storage/test/reference.test.ts index a1ed2585331..262c9519f2e 100644 --- a/packages/storage/test/reference.test.ts +++ b/packages/storage/test/reference.test.ts @@ -26,9 +26,16 @@ import { Service } from '../src/service'; import * as testShared from './testshared'; import { SendHook, TestingXhrIo } from './xhrio'; import { DEFAULT_HOST } from '../src/implementation/constants'; +import { FirebaseAuthInternal } from '@firebase/auth-interop-types'; +import { Provider } from '@firebase/component'; + /* eslint-disable @typescript-eslint/no-floating-promises */ -function makeFakeService(app: FirebaseApp, sendHook: SendHook): Service { - return new Service(app, testShared.makePool(sendHook)); +function makeFakeService( + app: FirebaseApp, + authProvider: Provider, + sendHook: SendHook +): Service { + return new Service(app, authProvider, testShared.makePool(sendHook)); } function makeStorage(url: string): Reference { @@ -38,6 +45,7 @@ function makeStorage(url: string): Reference { const authWrapper = new AuthWrapper( null, + testShared.emptyAuthProvider, maker, makeRequest, ({} as any) as Service, @@ -190,7 +198,11 @@ describe('Firebase Storage > Reference', () => { done(); } - const service = makeFakeService(testShared.fakeAppNoAuth, newSend); + const service = makeFakeService( + testShared.fakeApp, + testShared.emptyAuthProvider, + newSend + ); const ref = service.refFromURL('gs://test-bucket'); ref.child('foo').getMetadata(); }); @@ -212,7 +224,11 @@ describe('Firebase Storage > Reference', () => { done(); } - const service = makeFakeService(testShared.fakeApp, newSend); + const service = makeFakeService( + testShared.fakeApp, + testShared.fakeAuthProvider, + newSend + ); const ref = service.refFromURL('gs://test-bucket'); ref.child('foo').getMetadata(); }); diff --git a/packages/storage/test/requests.test.ts b/packages/storage/test/requests.test.ts index 270da7d3e2a..8a9b9366148 100644 --- a/packages/storage/test/requests.test.ts +++ b/packages/storage/test/requests.test.ts @@ -28,7 +28,11 @@ import { XhrIoPool } from '../src/implementation/xhriopool'; import { Metadata } from '../src/metadata'; import { Reference } from '../src/reference'; import { Service } from '../src/service'; -import { assertObjectIncludes, fakeXhrIo } from './testshared'; +import { + assertObjectIncludes, + fakeXhrIo, + fakeAuthProvider +} from './testshared'; import { DEFAULT_HOST, CONFIG_STORAGE_BUCKET_KEY @@ -56,11 +60,12 @@ describe('Firebase Storage > Requests', () => { [CONFIG_STORAGE_BUCKET_KEY]: 'fredzqm-staging' }, automaticDataCollectionEnabled: false, - delete: () => Promise.resolve() + delete: async () => undefined }; const authWrapper = new AuthWrapper( mockApp, + fakeAuthProvider, (authWrapper, loc) => { return new Reference(authWrapper, loc); }, diff --git a/packages/storage/test/service.test.ts b/packages/storage/test/service.test.ts index e7137f20464..241001620ac 100644 --- a/packages/storage/test/service.test.ts +++ b/packages/storage/test/service.test.ts @@ -22,9 +22,9 @@ import * as testShared from './testshared'; import { DEFAULT_HOST } from '../src/implementation/constants'; import { FirebaseStorageError } from '../src/implementation/error'; -const fakeAppGs = testShared.makeFakeApp(null, 'gs://mybucket'); -const fakeAppGsEndingSlash = testShared.makeFakeApp(null, 'gs://mybucket/'); -const fakeAppInvalidGs = testShared.makeFakeApp(null, 'gs://mybucket/hello'); +const fakeAppGs = testShared.makeFakeApp('gs://mybucket'); +const fakeAppGsEndingSlash = testShared.makeFakeApp('gs://mybucket/'); +const fakeAppInvalidGs = testShared.makeFakeApp('gs://mybucket/hello'); const xhrIoPool = new XhrIoPool(); function makeGsUrl(child: string = ''): string { @@ -33,7 +33,11 @@ function makeGsUrl(child: string = ''): string { describe('Firebase Storage > Service', () => { describe('simple constructor', () => { - const service = new Service(testShared.fakeApp, xhrIoPool); + const service = new Service( + testShared.fakeApp, + testShared.fakeAuthProvider, + xhrIoPool + ); it('Root refs point to the right place', () => { const ref = service.ref(); assert.equal(ref.toString(), makeGsUrl()); @@ -65,6 +69,7 @@ describe('Firebase Storage > Service', () => { it('gs:// custom bucket constructor refs point to the right place', () => { const service = new Service( testShared.fakeApp, + testShared.fakeAuthProvider, xhrIoPool, 'gs://foo-bar.appspot.com' ); @@ -74,6 +79,7 @@ describe('Firebase Storage > Service', () => { it('http:// custom bucket constructor refs point to the right place', () => { const service = new Service( testShared.fakeApp, + testShared.fakeAuthProvider, xhrIoPool, `http://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -83,6 +89,7 @@ describe('Firebase Storage > Service', () => { it('https:// custom bucket constructor refs point to the right place', () => { const service = new Service( testShared.fakeApp, + testShared.fakeAuthProvider, xhrIoPool, `https://${DEFAULT_HOST}/v1/b/foo-bar.appspot.com/o` ); @@ -93,6 +100,7 @@ describe('Firebase Storage > Service', () => { it('Bare bucket name constructor refs point to the right place', () => { const service = new Service( testShared.fakeApp, + testShared.fakeAuthProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -102,6 +110,7 @@ describe('Firebase Storage > Service', () => { it('Child refs point to the right place', () => { const service = new Service( testShared.fakeApp, + testShared.fakeAuthProvider, xhrIoPool, 'foo-bar.appspot.com' ); @@ -110,28 +119,45 @@ describe('Firebase Storage > Service', () => { }); it('Throws trying to construct with a gs:// URL containing an object path', () => { const error = testShared.assertThrows(() => { - new Service(testShared.fakeApp, xhrIoPool, 'gs://bucket/object/'); + new Service( + testShared.fakeApp, + testShared.fakeAuthProvider, + xhrIoPool, + 'gs://bucket/object/' + ); }, 'storage/invalid-default-bucket'); assert.match(error.message, /Invalid default bucket/); }); }); describe('default bucket config', () => { it('gs:// works without ending slash', () => { - const service = new Service(fakeAppGs, xhrIoPool); + const service = new Service( + fakeAppGs, + testShared.fakeAuthProvider, + xhrIoPool + ); assert.equal(service.ref().toString(), 'gs://mybucket/'); }); it('gs:// works with ending slash', () => { - const service = new Service(fakeAppGsEndingSlash, xhrIoPool); + const service = new Service( + fakeAppGsEndingSlash, + testShared.fakeAuthProvider, + xhrIoPool + ); assert.equal(service.ref().toString(), 'gs://mybucket/'); }); it('Throws when config bucket is gs:// with an object path', () => { testShared.assertThrows(() => { - new Service(fakeAppInvalidGs, xhrIoPool); + new Service(fakeAppInvalidGs, testShared.fakeAuthProvider, xhrIoPool); }, 'storage/invalid-default-bucket'); }); }); describe('refFromURL', () => { - const service = new Service(testShared.fakeApp, xhrIoPool); + const service = new Service( + testShared.fakeApp, + testShared.fakeAuthProvider, + xhrIoPool + ); it('Throws on non-URL arg', () => { const error = testShared.assertThrows(() => { service.refFromURL('path/to/child'); @@ -158,7 +184,11 @@ describe('Firebase Storage > Service', () => { }); }); describe('Argument verification', () => { - const service = new Service(testShared.fakeApp, xhrIoPool); + const service = new Service( + testShared.fakeApp, + testShared.fakeAuthProvider, + xhrIoPool + ); describe('ref', () => { it('Throws with two args', () => { testShared.assertThrows( @@ -272,7 +302,11 @@ describe('Firebase Storage > Service', () => { }); describe('Deletion', () => { - const service = new Service(testShared.fakeApp, xhrIoPool); + const service = new Service( + testShared.fakeApp, + testShared.fakeAuthProvider, + xhrIoPool + ); it('In-flight requests are canceled when the service is deleted', () => { const ref = service.refFromURL('gs://mybucket/image.jpg'); const toReturn = ref.getMetadata().then( diff --git a/packages/storage/test/task.test.ts b/packages/storage/test/task.test.ts index eebd1ccb090..8bbe4910944 100644 --- a/packages/storage/test/task.test.ts +++ b/packages/storage/test/task.test.ts @@ -26,7 +26,12 @@ import { Headers } from '../src/implementation/xhrio'; import { Reference } from '../src/reference'; import { Service } from '../src/service'; import { UploadTask } from '../src/task'; -import { assertThrows, bind as fbsBind, makePool } from './testshared'; +import { + assertThrows, + bind as fbsBind, + makePool, + emptyAuthProvider +} from './testshared'; import { StringHeaders, TestingXhrIo } from './xhrio'; const testLocation = new Location('bucket', 'object'); @@ -63,6 +68,7 @@ function authWrapperWithHandler(handler: RequestHandler): AuthWrapper { return new AuthWrapper( null, + emptyAuthProvider, () => { return {} as Reference; }, diff --git a/packages/storage/test/testshared.ts b/packages/storage/test/testshared.ts index 6f2906fb898..873caf4698d 100644 --- a/packages/storage/test/testshared.ts +++ b/packages/storage/test/testshared.ts @@ -22,18 +22,27 @@ import * as type from '../src/implementation/type'; import { Headers, XhrIo } from '../src/implementation/xhrio'; import { XhrIoPool } from '../src/implementation/xhriopool'; import { SendHook, StringHeaders, TestingXhrIo } from './xhrio'; +import { FirebaseAuthInternal } from '@firebase/auth-interop-types'; +import { + Provider, + ComponentContainer, + Component, + ComponentType +} from '@firebase/component'; export const authToken = 'totally-legit-auth-token'; export const bucket = 'mybucket'; -export const fakeApp = makeFakeApp({ accessToken: authToken }); -export const fakeAppNoAuth = makeFakeApp(null); +export const fakeApp = makeFakeApp(); +export const fakeAuthProvider = makeFakeAuthProvider({ + accessToken: authToken +}); +export const emptyAuthProvider = new Provider( + 'auth-internal', + new ComponentContainer('storage-container') +); -export function makeFakeApp(token: {} | null, bucketArg?: string): FirebaseApp { +export function makeFakeApp(bucketArg?: string): FirebaseApp { const app: any = {}; - app.INTERNAL = {}; - app.INTERNAL.getToken = function() { - return Promise.resolve(token); - }; app.options = {}; if (type.isDef(bucketArg)) { app.options[constants.CONFIG_STORAGE_BUCKET_KEY] = bucketArg; @@ -43,6 +52,26 @@ export function makeFakeApp(token: {} | null, bucketArg?: string): FirebaseApp { return app as FirebaseApp; } +export function makeFakeAuthProvider( + token: {} | null +): Provider { + const provider = new Provider( + 'auth-internal', + new ComponentContainer('storage-container') + ); + provider.setComponent( + new Component( + 'auth-internal', + () => ({ + getToken: async () => token + }), + ComponentType.PRIVATE + ) + ); + + return provider as Provider; +} + export function makePool(sendHook: SendHook | null): XhrIoPool { const pool: any = { createXhrIo() { diff --git a/packages/testing/src/api/index.ts b/packages/testing/src/api/index.ts index 7da634d9cb8..94b6eaf7918 100644 --- a/packages/testing/src/api/index.ts +++ b/packages/testing/src/api/index.ts @@ -16,12 +16,15 @@ */ import * as firebase from 'firebase'; +import { _FirebaseApp } from '@firebase/app-types/private'; +import { FirebaseAuthInternal } from '@firebase/auth-interop-types'; import * as request from 'request'; import { base64 } from '@firebase/util'; import { setLogLevel, LogLevel } from '@firebase/logger'; import * as grpc from 'grpc'; import * as protoLoader from '@grpc/proto-loader'; import { resolve } from 'path'; +import { Component, ComponentType } from '@firebase/component'; export { database, firestore } from 'firebase'; @@ -126,8 +129,21 @@ function initializeApp( let app = firebase.initializeApp(appOptions, appName); // hijacking INTERNAL.getToken to bypass FirebaseAuth and allows specifying of auth headers if (accessToken) { - (app as any).INTERNAL.getToken = () => - Promise.resolve({ accessToken: accessToken }); + const mockAuthComponent = new Component( + 'auth-internal', + () => + ({ + getToken: async () => ({ accessToken: accessToken }), + getUid: () => null, + addAuthTokenListener: () => {}, + removeAuthTokenListener: () => {} + } as FirebaseAuthInternal), + ComponentType.PRIVATE + ); + + ((app as unknown) as _FirebaseApp)._addOrOverwriteComponent( + mockAuthComponent + ); } if (databaseName) { // Toggle network connectivity to force a reauthentication attempt. diff --git a/packages/testing/test/database.test.ts b/packages/testing/test/database.test.ts index aa0b29d6bf8..f465324524e 100644 --- a/packages/testing/test/database.test.ts +++ b/packages/testing/test/database.test.ts @@ -19,6 +19,7 @@ import * as chai from 'chai'; import * as chaiAsPromised from 'chai-as-promised'; import * as firebase from '../src/api'; import { base64 } from '@firebase/util'; +import { _FirebaseApp } from '@firebase/app-types/private'; const expect = chai.expect; @@ -60,8 +61,12 @@ describe('Testing Module Tests', function() { projectId: 'foo', auth: undefined }); - const token = await (app as any).INTERNAL.getToken(); - expect(token).to.be.null; + + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate({ optional: true }); + // Auth instance will not be available because no API Key is provided + expect(authInternal).to.be.null; }); it('initializeTestApp() with auth sets the correct access token', async function() { @@ -70,10 +75,14 @@ describe('Testing Module Tests', function() { projectId: 'foo', auth: auth }); - const token = await (app as any).INTERNAL.getToken(); + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate(); + + const token = await authInternal.getToken(); expect(token).to.have.keys('accessToken'); const claims = JSON.parse( - base64.decodeString(token.accessToken.split('.')[1], /*webSafe=*/ false) + base64.decodeString(token!.accessToken.split('.')[1], /*webSafe=*/ false) ); // We add an 'iat' field. expect(claims).to.deep.equal({ uid: auth.uid, iat: 0, sub: auth.uid }); @@ -81,9 +90,13 @@ describe('Testing Module Tests', function() { it('initializeAdminApp() sets the access token to "owner"', async function() { const app = firebase.initializeAdminApp({ projectId: 'foo' }); - const token = await (app as any).INTERNAL.getToken(); + const authInternal = ((app as unknown) as _FirebaseApp).container + .getProvider('auth-internal') + .getImmediate(); + + const token = await authInternal.getToken(); expect(token).to.have.keys('accessToken'); - expect(token.accessToken).to.be.string('owner'); + expect(token!.accessToken).to.be.string('owner'); }); it('loadDatabaseRules() throws if no databaseName or rules', async function() {