diff --git a/etc/firebase-admin.app.api.md b/etc/firebase-admin.app.api.md index 97ab9667ce..f653df5a7b 100644 --- a/etc/firebase-admin.app.api.md +++ b/etc/firebase-admin.app.api.md @@ -83,10 +83,10 @@ export interface FirebaseError { toJSON(): object; } -// @public (undocumented) +// @public export function getApp(appName?: string): App; -// @public (undocumented) +// @public export function getApps(): App[]; // @public @@ -97,7 +97,7 @@ export interface GoogleOAuthAccessToken { expires_in: number; } -// @public (undocumented) +// @public export function initializeApp(options?: AppOptions, appName?: string): App; // @public diff --git a/package-lock.json b/package-lock.json index fc2251b97e..bc43fd2ed4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@firebase/database-types": "^1.0.6", "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", "google-auth-library": "^9.14.2", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", @@ -3317,6 +3318,37 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -4062,6 +4094,21 @@ "node": ">=6.0.0" } }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/duplexify": { "version": "4.1.3", "resolved": "https://registry.npmjs.org/duplexify/-/duplexify-4.1.3.tgz", @@ -4205,14 +4252,11 @@ } }, "node_modules/es-define-property": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.0.tgz", - "integrity": "sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.2.4" - }, "engines": { "node": ">= 0.4" } @@ -4228,9 +4272,9 @@ } }, "node_modules/es-object-atoms": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", - "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", "dev": true, "license": "MIT", "dependencies": { @@ -4618,7 +4662,6 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", - "devOptional": true, "license": "MIT" }, "node_modules/fast-fifo": { @@ -5312,17 +5355,22 @@ } }, "node_modules/get-intrinsic": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.2.4.tgz", - "integrity": "sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "dev": true, "license": "MIT", "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", "function-bind": "^1.1.2", - "has-proto": "^1.0.1", - "has-symbols": "^1.0.3", - "hasown": "^2.0.0" + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -5348,6 +5396,20 @@ "dev": true, "license": "MIT" }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/get-symbol-description": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", @@ -5622,13 +5684,13 @@ } }, "node_modules/gopd": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.0.1.tgz", - "integrity": "sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", "dev": true, "license": "MIT", - "dependencies": { - "get-intrinsic": "^1.1.3" + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -6236,9 +6298,9 @@ } }, "node_modules/has-symbols": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.0.3.tgz", - "integrity": "sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", "dev": true, "license": "MIT", "engines": { @@ -6561,15 +6623,15 @@ "license": "ISC" }, "node_modules/internal-slot": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", - "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", - "hasown": "^2.0.0", - "side-channel": "^1.0.4" + "hasown": "^2.0.2", + "side-channel": "^1.1.0" }, "engines": { "node": ">= 0.4" @@ -7934,6 +7996,16 @@ "dev": true, "license": "MIT" }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, "node_modules/memorystream": { "version": "0.3.1", "resolved": "https://registry.npmjs.org/memorystream/-/memorystream-0.3.1.tgz", @@ -8847,9 +8919,9 @@ } }, "node_modules/object-inspect": { - "version": "1.13.2", - "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.2.tgz", - "integrity": "sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==", + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", "dev": true, "license": "MIT", "engines": { @@ -10402,16 +10474,73 @@ } }, "node_modules/side-channel": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.0.6.tgz", - "integrity": "sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", "dev": true, "license": "MIT", "dependencies": { - "call-bind": "^1.0.7", "es-errors": "^1.3.0", - "get-intrinsic": "^1.2.4", - "object-inspect": "^1.13.1" + "object-inspect": "^1.13.3", + "side-channel-list": "^1.0.0", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.0.tgz", + "integrity": "sha512-FCLHtRD/gnpCiCHEiJLOwdmFP+wzCmDEkc9y7NsYxeF4u7Btsn1ZuwgwJGxImImHicJArLP4R0yX4c2KCrMrTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" }, "engines": { "node": ">= 0.4" diff --git a/package.json b/package.json index 1401c8ce6a..c7409d1cd0 100644 --- a/package.json +++ b/package.json @@ -209,6 +209,7 @@ "@firebase/database-types": "^1.0.6", "@types/node": "^22.8.7", "farmhash-modern": "^1.1.0", + "fast-deep-equal": "^3.1.1", "google-auth-library": "^9.14.2", "jsonwebtoken": "^9.0.0", "jwks-rsa": "^3.1.0", diff --git a/src/app/firebase-app.ts b/src/app/firebase-app.ts index a0ee93e1fe..982c8d14e8 100644 --- a/src/app/firebase-app.ts +++ b/src/app/firebase-app.ts @@ -118,7 +118,7 @@ export class FirebaseAppInternals { } private shouldRefresh(): boolean { - return (!this.cachedToken_ || (this.cachedToken_.expirationTime - Date.now()) <= TOKEN_EXPIRY_THRESHOLD_MILLIS) + return (!this.cachedToken_ || (this.cachedToken_.expirationTime - Date.now()) <= TOKEN_EXPIRY_THRESHOLD_MILLIS) && !this.isRefreshing; } @@ -157,10 +157,13 @@ export class FirebaseApp implements App { private options_: AppOptions; private services_: {[name: string]: unknown} = {}; private isDeleted_ = false; + private autoInit_ = false; + private customCredential_ = true; - constructor(options: AppOptions, name: string, private readonly appStore?: AppStore) { + constructor(options: AppOptions, name: string, autoInit: boolean = false, private readonly appStore?: AppStore) { this.name_ = name; this.options_ = deepCopy(options); + this.autoInit_ = autoInit; if (!validator.isNonNullObject(this.options_)) { throw new FirebaseAppError( @@ -172,6 +175,7 @@ export class FirebaseApp implements App { const hasCredential = ('credential' in this.options_); if (!hasCredential) { + this.customCredential_ = false; this.options_.credential = getApplicationDefault(this.options_.httpAgent); } @@ -215,6 +219,25 @@ export class FirebaseApp implements App { return this.ensureService_(name, () => init(this)); } + /** + * Returns `true` if this app was initialized with auto-initialization. + * + * @internal + */ + public autoInit(): boolean { + return this.autoInit_; + } + + /** + * Returns `true` if the `FirebaseApp` instance was initialized with a custom + * `Credential`. + * + * @internal + */ + public customCredential() : boolean { + return this.customCredential_; + } + /** * Deletes the FirebaseApp instance. * diff --git a/src/app/lifecycle.ts b/src/app/lifecycle.ts index 9c7cfdd31d..5160e51f5c 100644 --- a/src/app/lifecycle.ts +++ b/src/app/lifecycle.ts @@ -22,6 +22,7 @@ import { AppErrorCodes, FirebaseAppError } from '../utils/error'; import { App, AppOptions } from './core'; import { getApplicationDefault } from './credential-internal'; import { FirebaseApp } from './firebase-app'; +const fastDeepEqual = require('fast-deep-equal'); const DEFAULT_APP_NAME = '[DEFAULT]'; @@ -30,48 +31,60 @@ export class AppStore { private readonly appStore = new Map(); public initializeApp(options?: AppOptions, appName: string = DEFAULT_APP_NAME): App { + validateAppNameFormat(appName); + + let autoInit = false; if (typeof options === 'undefined') { + autoInit = true options = loadOptionsFromEnvVar(); options.credential = getApplicationDefault(); } - if (typeof appName !== 'string' || appName === '') { + // Check if an app already exists and, if so, ensure its `AppOptions` match + // those of this `initializeApp` request. + if (!this.appStore.has(appName)) { + const app = new FirebaseApp(options, appName, autoInit, this); + this.appStore.set(app.name, app); + return app; + } + + const currentApp = this.appStore.get(appName)!; + // Ensure the `autoInit` state matches the existing app's. If not, throw. + if (currentApp.autoInit() !== autoInit) { throw new FirebaseAppError( - AppErrorCodes.INVALID_APP_NAME, - `Invalid Firebase app name "${appName}" provided. App name must be a non-empty string.`, + AppErrorCodes.INVALID_APP_OPTIONS, + `A Firebase app named "${appName}" already exists with a different configuration.` + ) + } + + if (autoInit) { + // Auto-initialization is triggered when no options were passed to + // `initializeApp`. With no options to compare, simply return the App. + return currentApp; + } + + // Ensure the options objects don't break deep equal comparisons. + validateAppOptionsSupportDeepEquals(options, currentApp); + + // `FirebaseApp()` adds a synthesized `Credential` to `app.options` upon + // app construction. Run a comparison w/o `Credential` to see if the base + // configurations match. Return the existing app if so. + const currentAppOptions = { ...currentApp.options }; + delete currentAppOptions.credential; + if (!fastDeepEqual(options, currentAppOptions)) { + throw new FirebaseAppError( + AppErrorCodes.DUPLICATE_APP, + `A Firebase app named "${appName}" already exists with a different configuration.` ); - } else if (this.appStore.has(appName)) { - if (appName === DEFAULT_APP_NAME) { - throw new FirebaseAppError( - AppErrorCodes.DUPLICATE_APP, - 'The default Firebase app already exists. This means you called initializeApp() ' + - 'more than once without providing an app name as the second argument. In most cases ' + - 'you only need to call initializeApp() once. But if you do want to initialize ' + - 'multiple apps, pass a second argument to initializeApp() to give each app a unique ' + - 'name.', - ); - } else { - throw new FirebaseAppError( - AppErrorCodes.DUPLICATE_APP, - `Firebase app named "${appName}" already exists. This means you called initializeApp() ` + - 'more than once with the same app name as the second argument. Make sure you provide a ' + - 'unique name every time you call initializeApp().', - ); - } + } - const app = new FirebaseApp(options, appName, this); - this.appStore.set(app.name, app); - return app; + return currentApp; } public getApp(appName: string = DEFAULT_APP_NAME): App { - if (typeof appName !== 'string' || appName === '') { - throw new FirebaseAppError( - AppErrorCodes.INVALID_APP_NAME, - `Invalid Firebase app name "${appName}" provided. App name must be a non-empty string.`, - ); - } else if (!this.appStore.has(appName)) { + validateAppNameFormat(appName); + if (!this.appStore.has(appName)) { let errorMessage: string = (appName === DEFAULT_APP_NAME) ? 'The default Firebase app does not exist. ' : `Firebase app named "${appName}" does not exist. `; errorMessage += 'Make sure you call initializeApp() before using any of the Firebase services.'; @@ -119,16 +132,145 @@ export class AppStore { } } +/** + * Validates that the `requestedOptions` and the `existingApp` options objects + * do not have fields that would break deep equals comparisons. + * + * @param requestedOptions The incoming `AppOptions` of a new `initailizeApp` + * request. + * @param existingApp An existing `FirebaseApp` with internal `options` to + * compare against. + * + * @throws FirebaseAppError if the objects cannot be deeply compared. + * + * @internal + */ +function validateAppOptionsSupportDeepEquals( + requestedOptions: AppOptions, + existingApp: FirebaseApp): void { + + // http.Agent checks. + if (typeof requestedOptions.httpAgent !== 'undefined') { + throw new FirebaseAppError( + AppErrorCodes.INVALID_APP_OPTIONS, + `Firebase app named "${existingApp.name}" already exists and initializeApp was` + + ' invoked with an optional http.Agent. The SDK cannot confirm the equality' + + ' of http.Agent objects with the existing app. Please use getApp or getApps to reuse' + + ' the existing app instead.' + ); + } else if (typeof existingApp.options.httpAgent !== 'undefined') { + throw new FirebaseAppError( + AppErrorCodes.INVALID_APP_OPTIONS, + `An existing app named "${existingApp.name}" already exists with a different` + + ' options configuration: httpAgent.' + ); + } + + // Credential checks. + if (typeof requestedOptions.credential !== 'undefined') { + throw new FirebaseAppError( + AppErrorCodes.INVALID_APP_OPTIONS, + `Firebase app named "${existingApp.name}" already exists and initializeApp was` + + ' invoked with an optional Credential. The SDK cannot confirm the equality' + + ' of Credential objects with the existing app. Please use getApp or getApps' + + ' to reuse the existing app instead.' + ); + } + + if (existingApp.customCredential()) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_APP_OPTIONS, + `An existing app named "${existingApp.name}" already exists with a different` + + ' options configuration: Credential.' + ); + } +} + +/** + * Checks to see if the provided appName is a non-empty string and throws if it + * is not. + * + * @param appName A string representation of an App name. + * + * @throws FirebaseAppError if appName is not of type string or is empty. + * + * @internal + */ +function validateAppNameFormat(appName: string): void { + if (!validator.isNonEmptyString(appName)) { + throw new FirebaseAppError( + AppErrorCodes.INVALID_APP_NAME, + `Invalid Firebase app name "${appName}" provided. App name must be a non-empty string.`, + ); + } +} + export const defaultAppStore = new AppStore(); +/** + * Initializes the `App` instance. + * + * Creates a new instance of {@link App} if one doesn't exist, or returns an existing + * `App` instance if one exists with the same `appName` and `options`. + * + * Note, due to the inablity to compare `http.Agent` objects and `Credential` objects, + * this function cannot support idempotency if either of `options.httpAgent` or + * `options.credential` are defined. When either is defined, subsequent invocations will + * throw a `FirebaseAppError` instead of returning an `App` object. + * + * For example, to safely initialize an app that may already exist: + * + * ```javascript + * let app; + * try { + * app = getApp("myApp"); + * } catch (error) { + * app = initializeApp({ credential: myCredential }, "myApp"); + * } + * ``` + * + * @param options - Optional A set of {@link AppOptions} for the `App` instance. + * If not present, `initializeApp` will try to initialize with the options from the + * `FIREBASE_CONFIG` environment variable. If the environment variable contains a + * string that starts with `{` it will be parsed as JSON, otherwise it will be + * assumed to be pointing to a file. + * @param appName - Optional name of the `App` instance. + * + * @returns A new App instance, or the existing App if the instance already exists with + * the provided configuration. + * + * @throws FirebaseAppError if an `App` with the same name has already been + * initialized with a different set of `AppOptions`. + * @throws FirebaseAppError if an existing `App` exists and `options.httpAgent` + * or `options.credential` are defined. This is due to the function's inability to + * determine if the existing `App`'s `options` equate to the `options` parameter + * of this function. It's recommended to use {@link getApp} or {@link getApps} if your + * implementation uses either of these two fields in `AppOptions`. + */ export function initializeApp(options?: AppOptions, appName: string = DEFAULT_APP_NAME): App { return defaultAppStore.initializeApp(options, appName); } +/** + * Returns an existing {@link App} instance for the provided name. If no name + * is provided the the default app name is used. + * + * @param appName - Optional name of the `App` instance. + * + * @returns An existing `App` instance that matches the name provided. + * + * @throws FirebaseAppError if no `App` exists for the given name. + * @throws FirebaseAppError if the `appName` is malformed. + */ export function getApp(appName: string = DEFAULT_APP_NAME): App { return defaultAppStore.getApp(appName); } +/** + * A (read-only) array of all initialized apps. + * + * @returns An array containing all initialized apps. + */ export function getApps(): App[] { return defaultAppStore.getApps(); } diff --git a/test/resources/mocks.ts b/test/resources/mocks.ts index f9d08023d2..c6b86886a0 100644 --- a/test/resources/mocks.ts +++ b/test/resources/mocks.ts @@ -57,10 +57,15 @@ export const storageBucket = 'bucketName.appspot.com'; export const credential = cert(path.resolve(__dirname, './mock.key.json')); -export const appOptions: AppOptions = { - credential, +export const appOptionsWithoutCredential: AppOptions = { databaseURL, - storageBucket, + storageBucket +}; + +export const appOptions: AppOptions = { + ...appOptionsWithoutCredential, + credential + }; export const appOptionsWithOverride: AppOptions = { diff --git a/test/unit/app/firebase-app.spec.ts b/test/unit/app/firebase-app.spec.ts index df4fc84a67..a99e15d8f0 100644 --- a/test/unit/app/firebase-app.spec.ts +++ b/test/unit/app/firebase-app.spec.ts @@ -33,7 +33,7 @@ import { FirebaseNamespace } from '../../../src/app/firebase-namespace'; import { AppStore, FIREBASE_CONFIG_VAR } from '../../../src/app/lifecycle'; import { auth, messaging, machineLearning, storage, firestore, database, - instanceId, installations, projectManagement, securityRules , remoteConfig, appCheck, + instanceId, installations, projectManagement, securityRules, remoteConfig, appCheck, } from '../../../src/firebase-namespace-api'; import { FirebaseAppError, AppErrorCodes } from '../../../src/utils/error'; @@ -269,6 +269,15 @@ describe('FirebaseApp', () => { expect(app.options.storageBucket).to.be.undefined; }); + it('should not throw if initializeApp invoked with the same options', () => { + process.env[FIREBASE_CONFIG_VAR] = './test/resources/firebase_config.json'; + expect(() => { + const app = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + const app2 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + expect(app2).to.equal(app); + }).to.not.throw(); + }); + it('should init with application default creds when no options provided and env variable is not set', () => { const app = firebaseNamespace.initializeApp(); expect(app.options.credential).to.not.be.undefined; @@ -324,7 +333,7 @@ describe('FirebaseApp', () => { it('should call removeApp() on the Firebase namespace internals', () => { const store = new AppStore(); const stub = sinon.stub(store, 'removeApp').resolves(); - const app = new FirebaseApp(mockApp.options, mockApp.name, store); + const app = new FirebaseApp(mockApp.options, mockApp.name, /*autoInit=*/false, store); return app.delete().then(() => { expect(stub).to.have.been.calledOnce.and.calledWith(mocks.appName); }); diff --git a/test/unit/app/firebase-namespace.spec.ts b/test/unit/app/firebase-namespace.spec.ts index ee3cb951fb..38a4c49886 100644 --- a/test/unit/app/firebase-namespace.spec.ts +++ b/test/unit/app/firebase-namespace.spec.ts @@ -17,6 +17,7 @@ 'use strict'; +import http = require('http'); import path = require('path'); import * as _ from 'lodash'; @@ -49,7 +50,7 @@ import { getSdkVersion } from '../../../src/utils/index'; import { app, auth, messaging, machineLearning, storage, firestore, database, - instanceId, installations, projectManagement, securityRules , remoteConfig, appCheck, + instanceId, installations, projectManagement, securityRules, remoteConfig, appCheck, } from '../../../src/firebase-namespace-api'; import { AppCheck as AppCheckImpl } from '../../../src/app-check/app-check'; import { Auth as AuthImpl } from '../../../src/auth/auth'; @@ -89,6 +90,20 @@ const expect = chai.expect; const DEFAULT_APP_NAME = '[DEFAULT]'; const DEFAULT_APP_NOT_FOUND = 'The default Firebase app does not exist. Make sure you call initializeApp() ' + 'before using any of the Firebase services.'; +const INITIALIZE_APP_CREDENTIAL_EXISTANCE_MISMATCH = 'An existing app named "mock-app-name" already ' + + 'exists with a different options configuration: Credential'; +const INITIALIZE_APP_HTTP_AGENT_EXISTANCE_MISMATCH = 'An existing app named "mock-app-name" already ' + + 'exists with a different options configuration: httpAgent'; +const INITIALIZE_APP_NOT_IDEMPOTENT_CREDENTIAL = 'Firebase app named "mock-app-name" already exists and ' + + 'initializeApp was invoked with an optional Credential. The SDK cannot confirm the equality ' + + 'of Credential objects with the existing app. Please use getApp or getApps to reuse the ' + + 'existing app instead.' +const INITIALIZE_APP_NOT_IDEMPOTENT_HTTP_AGENT = 'Firebase app named "mock-app-name" already exists and ' + + 'initializeApp was invoked with an optional http.Agent. The SDK cannot confirm the equality ' + + 'of http.Agent objects with the existing app. Please use getApp or getApps to reuse the ' + + 'existing app instead.' + + describe('FirebaseNamespace', () => { let firebaseNamespace: FirebaseNamespace; @@ -207,33 +222,94 @@ describe('FirebaseNamespace', () => { }).to.throw('Invalid Firebase app name "" provided. App name must be a non-empty string.'); }); - it('should throw given a name corresponding to an existing app', () => { + it('should not throw given a name corresponding to an existing app', () => { + let app1: App | undefined; + let app2: App | undefined; expect(() => { - firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); - firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); - }).to.throw(`Firebase app named "${mocks.appName}" already exists.`); + app1 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + app2 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + }).to.not.throw(); + expect(app1).to.equal(app2); }); - it('should throw given no app name if the default app already exists', () => { + it('should not throw given no app name if the default app already exists', () => { + let app1: App | undefined; + let app2: App | undefined; expect(() => { - firebaseNamespace.initializeApp(mocks.appOptions); - firebaseNamespace.initializeApp(mocks.appOptions); - }).to.throw('The default Firebase app already exists.'); + app1 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential); + app2 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential); + }).to.not.throw(); + expect(app1).to.equal(app2); + }); + + it('should throw due to the Credential option being not supported by idemopotency', () => { + let app1: App | undefined; + let app2: App | undefined; + expect(() => { + app1 = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + app2 = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + }).to.throw(INITIALIZE_APP_NOT_IDEMPOTENT_CREDENTIAL); + expect(app1).to.not.be.undefined; + expect(app2).to.be.undefined; + }); + + it('should throw due to idempotency check on Credential option on second app.', () => { + let app1: App | undefined; + let app2: App | undefined; + expect(() => { + app1 = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + app2 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + }).to.throw(INITIALIZE_APP_CREDENTIAL_EXISTANCE_MISMATCH); + expect(app1).to.not.be.undefined; + expect(app2).to.be.undefined; + }); + it('should throw due to the httpAgent option being not supported by idemopotency', () => { + let app1: App | undefined; + let app2: App | undefined; + const httpAgent = new http.Agent(); + const appOptions = { + ...mocks.appOptionsWithoutCredential, + httpAgent + } expect(() => { - firebaseNamespace.initializeApp(mocks.appOptions); - firebaseNamespace.initializeApp(mocks.appOptions, DEFAULT_APP_NAME); - }).to.throw('The default Firebase app already exists.'); + app1 = firebaseNamespace.initializeApp(appOptions, mocks.appName); + app2 = firebaseNamespace.initializeApp(appOptions, mocks.appName); + }).to.throw(INITIALIZE_APP_NOT_IDEMPOTENT_HTTP_AGENT); + expect(app1).to.not.be.undefined; + expect(app2).to.be.undefined; + }); + it('should throw due to idempotency check on httpAgent option on second app.', () => { + let app1: App | undefined; + let app2: App | undefined; + const httpAgent = new http.Agent(); + const appOptions = { + ...mocks.appOptionsWithoutCredential, + httpAgent + } expect(() => { - firebaseNamespace.initializeApp(mocks.appOptions, DEFAULT_APP_NAME); - firebaseNamespace.initializeApp(mocks.appOptions); - }).to.throw('The default Firebase app already exists.'); + app1 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + app2 = firebaseNamespace.initializeApp(appOptions, mocks.appName); + }).to.throw(INITIALIZE_APP_NOT_IDEMPOTENT_HTTP_AGENT); + expect(app1).to.not.be.undefined; + expect(app2).to.be.undefined; + }); + it('should throw due to idempotency check on httpAgent option on first app but not second app.', () => { + let app1: App | undefined; + let app2: App | undefined; + const httpAgent = new http.Agent(); + const appOptions = { + ...mocks.appOptionsWithoutCredential, + httpAgent + } expect(() => { - firebaseNamespace.initializeApp(mocks.appOptions, DEFAULT_APP_NAME); - firebaseNamespace.initializeApp(mocks.appOptions, DEFAULT_APP_NAME); - }).to.throw('The default Firebase app already exists.'); + app1 = firebaseNamespace.initializeApp(appOptions, mocks.appName); + app2 = firebaseNamespace.initializeApp(mocks.appOptionsWithoutCredential, mocks.appName); + }).to.throw(INITIALIZE_APP_HTTP_AGENT_EXISTANCE_MISMATCH); + expect(app1).to.not.be.undefined; + expect(app2).to.be.undefined; }); it('should return a new app with the provided options and app name', () => { @@ -248,10 +324,12 @@ describe('FirebaseNamespace', () => { }); it('should allow re-use of a deleted app name', () => { - let app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); - return app.delete().then(() => { - app = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); - expect(firebaseNamespace.app(mocks.appName)).to.deep.equal(app); + const app1 = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + let app2: App | undefined; + return app1.delete().then(() => { + app2 = firebaseNamespace.initializeApp(mocks.appOptions, mocks.appName); + expect(firebaseNamespace.app(mocks.appName)).to.deep.equal(app2); + expect(app2).to.not.equal(app1); }); });