diff --git a/.changeset/mighty-insects-judge.md b/.changeset/mighty-insects-judge.md new file mode 100644 index 00000000000..701e3b28e27 --- /dev/null +++ b/.changeset/mighty-insects-judge.md @@ -0,0 +1,13 @@ +--- +'@firebase/app': minor +'@firebase/app-types': minor +'@firebase/util': minor +'@firebase/auth': patch +'@firebase/database': patch +'@firebase/firestore': patch +'@firebase/functions': patch +'@firebase/storage': patch +'firebase': minor +--- + +Add functionality to auto-initialize project config and emulator settings from global defaults provided by framework tooling. diff --git a/common/api-review/app.api.md b/common/api-review/app.api.md index 8d35bc4096f..52f134dac16 100644 --- a/common/api-review/app.api.md +++ b/common/api-review/app.api.md @@ -93,6 +93,9 @@ export function initializeApp(options: FirebaseOptions, name?: string): Firebase // @public export function initializeApp(options: FirebaseOptions, config?: FirebaseAppSettings): FirebaseApp; +// @public +export function initializeApp(): FirebaseApp; + // @public export function onLog(logCallback: LogCallback | null, options?: LogOptions): void; diff --git a/common/api-review/firestore.api.md b/common/api-review/firestore.api.md index 3b6538d867b..e3a60e64129 100644 --- a/common/api-review/firestore.api.md +++ b/common/api-review/firestore.api.md @@ -228,10 +228,10 @@ export function getDocsFromCache(query: Query): Promise>; export function getDocsFromServer(query: Query): Promise>; // @public -export function getFirestore(): Firestore; +export function getFirestore(app: FirebaseApp): Firestore; // @public -export function getFirestore(app: FirebaseApp): Firestore; +export function getFirestore(): Firestore; // @public export function increment(n: number): FieldValue; diff --git a/common/api-review/util.api.md b/common/api-review/util.api.md index 25657980b0e..57e18bd3d2b 100644 --- a/common/api-review/util.api.md +++ b/common/api-review/util.api.md @@ -173,11 +173,28 @@ export function errorPrefix(fnName: string, argName: string): string; // @public (undocumented) export type Executor = (observer: Observer) => void; +// @public +export type ExperimentalKey = 'authTokenSyncURL' | 'authIdTokenMaxAge'; + // Warning: (ae-missing-release-tag) "extractQuerystring" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public export function extractQuerystring(url: string): string; +// @public +export interface FirebaseDefaults { + // (undocumented) + [key: string]: unknown; + // (undocumented) + _authIdTokenMaxAge?: number; + // (undocumented) + _authTokenSyncURL?: string; + // (undocumented) + config?: Record; + // (undocumented) + emulatorHosts?: Record; +} + // Warning: (ae-missing-release-tag) "FirebaseError" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public (undocumented) @@ -195,6 +212,15 @@ export class FirebaseError extends Error { // @public export type FirebaseSignInProvider = 'custom' | 'email' | 'password' | 'phone' | 'anonymous' | 'google.com' | 'facebook.com' | 'github.com' | 'twitter.com' | 'microsoft.com' | 'apple.com'; +// @public +export const getDefaultAppConfig: () => Record | undefined; + +// @public +export const getDefaultEmulatorHost: (productName: string) => string | undefined; + +// @public +export const getExperimentalSetting: (name: T) => FirebaseDefaults[`_${T}`]; + // Warning: (ae-missing-release-tag) "getGlobal" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/packages/app/src/api.ts b/packages/app/src/api.ts index 1a713b242c1..b76a00e2d1b 100644 --- a/packages/app/src/api.ts +++ b/packages/app/src/api.ts @@ -39,7 +39,7 @@ import { LogOptions, setUserLogHandler } from '@firebase/logger'; -import { deepEqual } from '@firebase/util'; +import { deepEqual, getDefaultAppConfig } from '@firebase/util'; export { FirebaseError } from '@firebase/util'; @@ -110,10 +110,18 @@ export function initializeApp( options: FirebaseOptions, config?: FirebaseAppSettings ): FirebaseApp; +/** + * Creates and initializes a FirebaseApp instance. + * + * @public + */ +export function initializeApp(): FirebaseApp; export function initializeApp( - options: FirebaseOptions, + _options?: FirebaseOptions, rawConfig = {} ): FirebaseApp { + let options = _options; + if (typeof rawConfig !== 'object') { const name = rawConfig; rawConfig = { name }; @@ -132,6 +140,12 @@ export function initializeApp( }); } + options ||= getDefaultAppConfig(); + + if (!options) { + throw ERROR_FACTORY.create(AppError.NO_OPTIONS); + } + const existingApp = _apps.get(name) as FirebaseAppImpl; if (existingApp) { // return the existing app if options and config deep equal the ones in the existing app. @@ -188,6 +202,9 @@ export function initializeApp( */ export function getApp(name: string = DEFAULT_ENTRY_NAME): FirebaseApp { const app = _apps.get(name); + if (!app && name === DEFAULT_ENTRY_NAME) { + return initializeApp(); + } if (!app) { throw ERROR_FACTORY.create(AppError.NO_APP, { appName: name }); } diff --git a/packages/app/src/errors.ts b/packages/app/src/errors.ts index ed307085b09..6cd50e90942 100644 --- a/packages/app/src/errors.ts +++ b/packages/app/src/errors.ts @@ -22,6 +22,7 @@ export const enum AppError { BAD_APP_NAME = 'bad-app-name', DUPLICATE_APP = 'duplicate-app', APP_DELETED = 'app-deleted', + NO_OPTIONS = 'no-options', INVALID_APP_ARGUMENT = 'invalid-app-argument', INVALID_LOG_ARGUMENT = 'invalid-log-argument', IDB_OPEN = 'idb-open', @@ -38,6 +39,8 @@ const ERRORS: ErrorMap = { [AppError.DUPLICATE_APP]: "Firebase App named '{$appName}' already exists with different options or config", [AppError.APP_DELETED]: "Firebase App named '{$appName}' already deleted", + [AppError.NO_OPTIONS]: + 'Need to provide options, when not being deployed to hosting via source.', [AppError.INVALID_APP_ARGUMENT]: 'firebase.{$appName}() takes either no argument or a ' + 'Firebase App instance.', diff --git a/packages/auth/src/platform_browser/index.ts b/packages/auth/src/platform_browser/index.ts index 3d206c3706d..9bfe4e3b01a 100644 --- a/packages/auth/src/platform_browser/index.ts +++ b/packages/auth/src/platform_browser/index.ts @@ -17,14 +17,50 @@ import { FirebaseApp, getApp, _getProvider } from '@firebase/app'; -import { initializeAuth } from '..'; +import { + initializeAuth, + beforeAuthStateChanged, + onIdTokenChanged, + connectAuthEmulator +} from '..'; import { registerAuth } from '../core/auth/register'; import { ClientPlatform } from '../core/util/version'; import { browserLocalPersistence } from './persistence/local_storage'; import { browserSessionPersistence } from './persistence/session_storage'; import { indexedDBLocalPersistence } from './persistence/indexed_db'; import { browserPopupRedirectResolver } from './popup_redirect'; -import { Auth } from '../model/public_types'; +import { Auth, User } from '../model/public_types'; +import { getDefaultEmulatorHost, getExperimentalSetting } from '@firebase/util'; + +const DEFAULT_ID_TOKEN_MAX_AGE = 5 * 60; +const authIdTokenMaxAge = + getExperimentalSetting('authIdTokenMaxAge') || DEFAULT_ID_TOKEN_MAX_AGE; + +let lastPostedIdToken: string | undefined | null = null; + +const mintCookieFactory = (url: string) => async (user: User | null) => { + const idTokenResult = user && (await user.getIdTokenResult()); + const idTokenAge = + idTokenResult && + (new Date().getTime() - Date.parse(idTokenResult.issuedAtTime)) / 1_000; + if (idTokenAge && idTokenAge > authIdTokenMaxAge) { + return; + } + // Specifically trip null => undefined when logged out, to delete any existing cookie + const idToken = idTokenResult?.token; + if (lastPostedIdToken === idToken) { + return; + } + lastPostedIdToken = idToken; + await fetch(url, { + method: idToken ? 'POST' : 'DELETE', + headers: idToken + ? { + 'Authorization': `Bearer ${idToken}` + } + : {} + }); +}; /** * Returns the Auth instance associated with the provided {@link @firebase/app#FirebaseApp}. @@ -41,7 +77,7 @@ export function getAuth(app: FirebaseApp = getApp()): Auth { return provider.getImmediate(); } - return initializeAuth(app, { + const auth = initializeAuth(app, { popupRedirectResolver: browserPopupRedirectResolver, persistence: [ indexedDBLocalPersistence, @@ -49,6 +85,22 @@ export function getAuth(app: FirebaseApp = getApp()): Auth { browserSessionPersistence ] }); + + const authTokenSyncUrl = getExperimentalSetting('authTokenSyncURL'); + if (authTokenSyncUrl) { + const mintCookie = mintCookieFactory(authTokenSyncUrl); + beforeAuthStateChanged(auth, mintCookie, () => + mintCookie(auth.currentUser) + ); + onIdTokenChanged(auth, user => mintCookie(user)); + } + + const authEmulatorHost = getDefaultEmulatorHost('auth'); + if (authEmulatorHost) { + connectAuthEmulator(auth, `http://${authEmulatorHost}`); + } + + return auth; } registerAuth(ClientPlatform.BROWSER); diff --git a/packages/auth/src/platform_node/index.ts b/packages/auth/src/platform_node/index.ts index 14f5a531b93..570f1e0589f 100644 --- a/packages/auth/src/platform_node/index.ts +++ b/packages/auth/src/platform_node/index.ts @@ -21,13 +21,14 @@ import { _createError } from '../core/util/assert'; import { FirebaseApp, getApp, _getProvider } from '@firebase/app'; import { Auth } from '../model/public_types'; -import { initializeAuth, inMemoryPersistence } from '..'; +import { initializeAuth, inMemoryPersistence, connectAuthEmulator } from '..'; import { registerAuth } from '../core/auth/register'; import { ClientPlatform } from '../core/util/version'; import { AuthImpl } from '../core/auth/auth_impl'; import { FetchProvider } from '../core/util/fetch_provider'; import * as fetchImpl from 'node-fetch'; +import { getDefaultEmulatorHost } from '@firebase/util'; // Initialize the fetch polyfill, the types are slightly off so just cast and hope for the best FetchProvider.initialize( @@ -46,7 +47,14 @@ export function getAuth(app: FirebaseApp = getApp()): Auth { return provider.getImmediate(); } - return initializeAuth(app); + const auth = initializeAuth(app); + + const authEmulatorHost = getDefaultEmulatorHost('auth'); + if (authEmulatorHost) { + connectAuthEmulator(auth, `http://${authEmulatorHost}`); + } + + return auth; } registerAuth(ClientPlatform.NODE); diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 840e1be5aff..db0273b1721 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -27,7 +27,8 @@ import { Provider } from '@firebase/component'; import { getModularInstance, createMockUserToken, - EmulatorMockTokenOptions + EmulatorMockTokenOptions, + getDefaultEmulatorHost } from '@firebase/util'; import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; @@ -316,9 +317,15 @@ export function getDatabase( app: FirebaseApp = getApp(), url?: string ): Database { - return _getProvider(app, 'database').getImmediate({ + const db = _getProvider(app, 'database').getImmediate({ identifier: url }) as Database; + const databaseEmulatorHost = getDefaultEmulatorHost('database'); + if (databaseEmulatorHost) { + const [host, port] = databaseEmulatorHost.split(':'); + connectDatabaseEmulator(db, host, parseInt(port, 10)); + } + return db; } /** diff --git a/packages/firestore/externs.json b/packages/firestore/externs.json index a4c36074978..fcd6408548c 100644 --- a/packages/firestore/externs.json +++ b/packages/firestore/externs.json @@ -26,6 +26,7 @@ "packages/logger/dist/src/logger.d.ts", "packages/webchannel-wrapper/src/index.d.ts", "packages/util/dist/src/crypt.d.ts", + "packages/util/dist/src/defaults.d.ts", "packages/util/dist/src/emulator.d.ts", "packages/util/dist/src/environment.d.ts", "packages/util/dist/src/compat.d.ts", diff --git a/packages/firestore/src/api/database.ts b/packages/firestore/src/api/database.ts index 6dbd4463cb3..d61b1ffe21a 100644 --- a/packages/firestore/src/api/database.ts +++ b/packages/firestore/src/api/database.ts @@ -15,14 +15,13 @@ * limitations under the License. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { _getProvider, _removeServiceInstance, FirebaseApp, getApp } from '@firebase/app'; -import { deepEqual } from '@firebase/util'; +import { deepEqual, getDefaultEmulatorHost } from '@firebase/util'; import { User } from '../auth/user'; import { @@ -43,7 +42,10 @@ import { setOnlineComponentProvider } from '../core/firestore_client'; import { makeDatabaseInfo } from '../lite-api/components'; -import { Firestore as LiteFirestore } from '../lite-api/database'; +import { + Firestore as LiteFirestore, + connectFirestoreEmulator +} from '../lite-api/database'; import { Query } from '../lite-api/reference'; import { indexedDbClearPersistence, @@ -186,14 +188,6 @@ export function initializeFirestore( }); } -/** - * Returns the existing default {@link Firestore} instance that is associated with the - * default {@link @firebase/app#FirebaseApp}. If no instance exists, initializes a new - * instance with default settings. - * - * @returns The {@link Firestore} instance of the provided app. - */ -export function getFirestore(): Firestore; /** * Returns the existing default {@link Firestore} instance that is associated with the * provided {@link @firebase/app#FirebaseApp}. If no instance exists, initializes a new @@ -215,7 +209,15 @@ export function getFirestore(app: FirebaseApp): Firestore; */ export function getFirestore(databaseId: string): Firestore; /** - * Returns the existing {@link Firestore} instance that is associated with the + * Returns the existing default {@link Firestore} instance that is associated with the + * default {@link @firebase/app#FirebaseApp}. If no instance exists, initializes a new + * instance with default settings. + * + * @returns The {@link Firestore} instance of the provided app. + */ +export function getFirestore(): Firestore; +/** + * Returns the existing default {@link Firestore} instance that is associated with the * provided {@link @firebase/app#FirebaseApp}. If no instance exists, initializes a new * instance with default settings. * @@ -236,9 +238,17 @@ export function getFirestore( typeof appOrDatabaseId === 'string' ? appOrDatabaseId : optionalDatabaseId || DEFAULT_DATABASE_NAME; - return _getProvider(app, 'firestore').getImmediate({ + const db = _getProvider(app, 'firestore').getImmediate({ identifier: databaseId }) as Firestore; + if (!db._initialized) { + const firestoreEmulatorHost = getDefaultEmulatorHost('firestore'); + if (firestoreEmulatorHost) { + const [host, port] = firestoreEmulatorHost.split(':'); + connectFirestoreEmulator(db, host, parseInt(port, 10)); + } + } + return db; } /** diff --git a/packages/firestore/src/lite-api/database.ts b/packages/firestore/src/lite-api/database.ts index c7ab4d5b437..dc6d509a6b5 100644 --- a/packages/firestore/src/lite-api/database.ts +++ b/packages/firestore/src/lite-api/database.ts @@ -22,7 +22,11 @@ import { FirebaseApp, getApp } from '@firebase/app'; -import { createMockUserToken, EmulatorMockTokenOptions } from '@firebase/util'; +import { + createMockUserToken, + EmulatorMockTokenOptions, + getDefaultEmulatorHost +} from '@firebase/util'; import { CredentialsProvider, @@ -44,6 +48,8 @@ import { FirestoreSettings } from './settings'; +export { EmulatorMockTokenOptions } from '@firebase/util'; + declare module '@firebase/component' { interface NameServiceMapping { 'firestore/lite': Firestore; @@ -260,12 +266,19 @@ export function getFirestore( typeof appOrDatabaseId === 'string' ? appOrDatabaseId : optionalDatabaseId || '(default)'; - return _getProvider(app, 'firestore/lite').getImmediate({ + const db = _getProvider(app, 'firestore/lite').getImmediate({ identifier: databaseId }) as Firestore; + if (!db._initialized) { + const firestoreEmulatorHost = getDefaultEmulatorHost('firestore'); + if (firestoreEmulatorHost) { + const [host, port] = firestoreEmulatorHost.split(':'); + connectFirestoreEmulator(db, host, parseInt(port, 10)); + } + } + return db; } -export { EmulatorMockTokenOptions } from '@firebase/util'; /** * Modify this instance to communicate with the Cloud Firestore emulator. * diff --git a/packages/functions/src/api.ts b/packages/functions/src/api.ts index 52913263041..f6b5066b9a8 100644 --- a/packages/functions/src/api.ts +++ b/packages/functions/src/api.ts @@ -27,7 +27,7 @@ import { httpsCallable as _httpsCallable, httpsCallableFromURL as _httpsCallableFromURL } from './service'; -import { getModularInstance } from '@firebase/util'; +import { getModularInstance, getDefaultEmulatorHost } from '@firebase/util'; export * from './public-types'; @@ -51,6 +51,12 @@ export function getFunctions( const functionsInstance = functionsProvider.getImmediate({ identifier: regionOrCustomDomain }); + const functionsEmulatorHost = getDefaultEmulatorHost('functions'); + if (functionsEmulatorHost) { + const [host, port] = functionsEmulatorHost.split(':'); + // eslint-disable-next-line no-restricted-globals + connectFunctionsEmulator(functionsInstance, host, parseInt(port, 10)); + } return functionsInstance; } diff --git a/packages/storage/src/api.ts b/packages/storage/src/api.ts index a489cacccb8..4b59c310543 100644 --- a/packages/storage/src/api.ts +++ b/packages/storage/src/api.ts @@ -14,7 +14,6 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// eslint-disable-next-line import/no-extraneous-dependencies import { _getProvider, FirebaseApp, getApp } from '@firebase/app'; import { @@ -51,7 +50,11 @@ import { getBytesInternal } from './reference'; import { STORAGE_TYPE } from './constants'; -import { EmulatorMockTokenOptions, getModularInstance } from '@firebase/util'; +import { + EmulatorMockTokenOptions, + getModularInstance, + getDefaultEmulatorHost +} from '@firebase/util'; import { StringFormat } from './implementation/string'; export { EmulatorMockTokenOptions } from '@firebase/util'; @@ -331,6 +334,12 @@ export function getStorage( const storageInstance = storageProvider.getImmediate({ identifier: bucketUrl }); + const storageEmulatorHost = getDefaultEmulatorHost('storage'); + if (storageEmulatorHost) { + const [host, port] = storageEmulatorHost.split(':'); + // eslint-disable-next-line no-restricted-globals + connectStorageEmulator(storageInstance, host, parseInt(port, 10)); + } return storageInstance; } diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index d39d5253c1f..d63584284ac 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -24,6 +24,7 @@ export * from './src/assert'; export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; +export * from './src/defaults'; export * from './src/deferred'; export * from './src/emulator'; export * from './src/environment'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 3f38c307eb3..c529580b24e 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -19,6 +19,7 @@ export * from './src/assert'; export * from './src/crypt'; export * from './src/constants'; export * from './src/deepCopy'; +export * from './src/defaults'; export * from './src/deferred'; export * from './src/emulator'; export * from './src/environment'; diff --git a/packages/util/src/defaults.ts b/packages/util/src/defaults.ts new file mode 100644 index 00000000000..6acc5886b5c --- /dev/null +++ b/packages/util/src/defaults.ts @@ -0,0 +1,133 @@ +/** + * @license + * Copyright 2022 Google LLC + * + * 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 { base64Decode } from './crypt'; +import { getGlobal } from './environment'; + +/** + * Keys for experimental properties on the `FirebaseDefaults` object. + * @public + */ +export type ExperimentalKey = 'authTokenSyncURL' | 'authIdTokenMaxAge'; + +/** + * An object that can be injected into the environment as __FIREBASE_DEFAULTS__, + * either as a property of globalThis, a shell environment variable, or a + * cookie. + * + * This object can be used to automatically configure and initialize + * a Firebase app as well as any emulators. + * + * @public + */ +export interface FirebaseDefaults { + config?: Record; + emulatorHosts?: Record; + _authTokenSyncURL?: string; + _authIdTokenMaxAge?: number; + [key: string]: unknown; +} + +declare global { + // Need `var` for this to work. + // eslint-disable-next-line no-var + var __FIREBASE_DEFAULTS__: FirebaseDefaults | undefined; +} + +const getDefaultsFromGlobal = (): FirebaseDefaults | undefined => + getGlobal().__FIREBASE_DEFAULTS__; + +/** + * Attempt to read defaults from a JSON string provided to + * process.env.__FIREBASE_DEFAULTS__ or a JSON file whose path is in + * process.env.__FIREBASE_DEFAULTS_PATH__ + */ +const getDefaultsFromEnvVariable = (): FirebaseDefaults | undefined => { + if (typeof process === 'undefined') { + return; + } + const defaultsJsonString = process.env.__FIREBASE_DEFAULTS__; + const defaultsJsonPath = process.env.__FIREBASE_DEFAULTS_PATH__; + if (defaultsJsonString) { + if (defaultsJsonPath) { + console.warn( + `Values were provided for both __FIREBASE_DEFAULTS__ ` + + `and __FIREBASE_DEFAULTS_PATH__. __FIREBASE_DEFAULTS_PATH__ ` + + `will be ignored.` + ); + } + return JSON.parse(defaultsJsonString); + } + if (defaultsJsonPath && typeof require !== 'undefined') { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const json = require(defaultsJsonPath); + return json; + } catch (e) { + console.warn( + `Unable to read defaults from file provided to ` + + `__FIREBASE_DEFAULTS_PATH__: ${defaultsJsonPath}` + ); + } + } +}; + +const getDefaultsFromCookie = (): FirebaseDefaults | undefined => { + if (typeof document === 'undefined') { + return; + } + const match = document.cookie.match(/__FIREBASE_DEFAULTS__=([^;]+)/); + const decoded = match && base64Decode(match[1]); + return decoded && JSON.parse(decoded); +}; + +/** + * Get the __FIREBASE_DEFAULTS__ object. It checks in order: + * (1) if such an object exists as a property of `globalThis` + * (2) if such an object was provided on a shell environment variable + * (3) if such an object exists in a cookie + */ +const getDefaults = (): FirebaseDefaults | undefined => + getDefaultsFromGlobal() || + getDefaultsFromEnvVariable() || + getDefaultsFromCookie(); + +/** + * Returns emulator host stored in the __FIREBASE_DEFAULTS__ object + * for the given product. + * @public + */ +export const getDefaultEmulatorHost = ( + productName: string +): string | undefined => getDefaults()?.emulatorHosts?.[productName]; + +/** + * Returns Firebase app config stored in the __FIREBASE_DEFAULTS__ object. + * @public + */ +export const getDefaultAppConfig = (): Record | undefined => + getDefaults()?.config; + +/** + * Returns an experimental setting on the __FIREBASE_DEFAULTS__ object (properties + * prefixed by "_") + * @public + */ +export const getExperimentalSetting = ( + name: T +): FirebaseDefaults[`_${T}`] => + getDefaults()?.[`_${name}`] as FirebaseDefaults[`_${T}`];