diff --git a/.changeset/three-singers-wonder.md b/.changeset/three-singers-wonder.md new file mode 100644 index 00000000000..72dea9d7aa5 --- /dev/null +++ b/.changeset/three-singers-wonder.md @@ -0,0 +1,10 @@ +--- +"@firebase/auth": patch +"@firebase/firestore": patch +"@firebase/util": patch +"@firebase/database": patch +"@firebase/storage": patch +"@firebase/functions": patch +--- + +Add Emulator Overlay diff --git a/common/api-review/util.api.md b/common/api-review/util.api.md index 0f8fc13cd3a..98f8657fd5d 100644 --- a/common/api-review/util.api.md +++ b/common/api-review/util.api.md @@ -482,6 +482,9 @@ export interface Subscribe { // @public (undocumented) export type Unsubscribe = () => void; +// @public +export function updateEmulatorBanner(name: string, isRunningEmulator: boolean): void; + // Warning: (ae-missing-release-tag) "validateArgCount" is exported by the package, but it is missing a release tag (@alpha, @beta, @public, or @internal) // // @public diff --git a/packages/auth/src/core/auth/emulator.ts b/packages/auth/src/core/auth/emulator.ts index 8547f7bad6c..42fbda3f095 100644 --- a/packages/auth/src/core/auth/emulator.ts +++ b/packages/auth/src/core/auth/emulator.ts @@ -18,7 +18,12 @@ import { Auth } from '../../model/public_types'; import { AuthErrorCode } from '../errors'; import { _assert } from '../util/assert'; import { _castAuth } from './auth_impl'; -import { deepEqual, isCloudWorkstation, pingServer } from '@firebase/util'; +import { + deepEqual, + isCloudWorkstation, + pingServer, + updateEmulatorBanner +} from '@firebase/util'; /** * Changes the {@link Auth} instance to communicate with the Firebase Auth Emulator, instead of production @@ -97,13 +102,12 @@ export function connectAuthEmulator( authInternal.emulatorConfig = emulatorConfig; authInternal.settings.appVerificationDisabledForTesting = true; - if (!disableWarnings) { - emitEmulatorWarning(); - } - - // Workaround to get cookies in Firebase Studio if (isCloudWorkstation(host)) { + updateEmulatorBanner('Auth', true); + // Workaround to get cookies in Firebase Studio void pingServer(`${protocol}//${host}${portStr}`); + } else if (!disableWarnings) { + emitEmulatorWarning(); } } diff --git a/packages/auth/src/platform_browser/index.ts b/packages/auth/src/platform_browser/index.ts index f94525bfeb7..99ab834cbdb 100644 --- a/packages/auth/src/platform_browser/index.ts +++ b/packages/auth/src/platform_browser/index.ts @@ -30,7 +30,11 @@ import { browserSessionPersistence } from './persistence/session_storage'; import { indexedDBLocalPersistence } from './persistence/indexed_db'; import { browserPopupRedirectResolver } from './popup_redirect'; import { Auth, User } from '../model/public_types'; -import { getDefaultEmulatorHost, getExperimentalSetting } from '@firebase/util'; +import { + getDefaultEmulatorHost, + getExperimentalSetting, + updateEmulatorBanner +} from '@firebase/util'; import { _setExternalJSProvider } from './load_js'; import { _createError } from '../core/util/assert'; import { AuthErrorCode } from '../core/errors'; @@ -110,6 +114,8 @@ export function getAuth(app: FirebaseApp = getApp()): Auth { const authEmulatorHost = getDefaultEmulatorHost('auth'); if (authEmulatorHost) { connectAuthEmulator(auth, `http://${authEmulatorHost}`); + } else { + updateEmulatorBanner('Auth', false); } return auth; diff --git a/packages/database/src/api/Database.ts b/packages/database/src/api/Database.ts index 515e278b5c5..a94b04518d7 100644 --- a/packages/database/src/api/Database.ts +++ b/packages/database/src/api/Database.ts @@ -31,7 +31,8 @@ import { EmulatorMockTokenOptions, getDefaultEmulatorHostnameAndPort, isCloudWorkstation, - pingServer + pingServer, + updateEmulatorBanner } from '@firebase/util'; import { AppCheckTokenProvider } from '../core/AppCheckTokenProvider'; @@ -257,6 +258,10 @@ export class Database implements _FirebaseService { this.app.options['databaseAuthVariableOverride'] ); this._instanceStarted = true; + updateEmulatorBanner( + 'Database', + this._repo.repoInfo_.emulatorOptions !== null + ); } return this._repoInternal; } @@ -393,6 +398,7 @@ export function connectDatabaseEmulator( // Workaround to get cookies in Firebase Studio if (isCloudWorkstation(host)) { void pingServer(host); + updateEmulatorBanner('Database', true); } // Modify the repo to apply emulator settings diff --git a/packages/firestore/src/lite-api/database.ts b/packages/firestore/src/lite-api/database.ts index 8e7fdb27e90..fce6d5843b7 100644 --- a/packages/firestore/src/lite-api/database.ts +++ b/packages/firestore/src/lite-api/database.ts @@ -28,6 +28,7 @@ import { EmulatorMockTokenOptions, getDefaultEmulatorHostnameAndPort, isCloudWorkstation, + updateEmulatorBanner, pingServer } from '@firebase/util'; @@ -142,6 +143,7 @@ export class Firestore implements FirestoreService { _freezeSettings(): FirestoreSettingsImpl { this._settingsFrozen = true; + updateEmulatorBanner('Firestore', this._settings.isUsingEmulator); return this._settings; } @@ -334,9 +336,7 @@ export function connectFirestoreEmulator( emulatorOptions: firestore._getEmulatorOptions() }; const newHostSetting = `${host}:${port}`; - if (useSsl) { - void pingServer(`https://${newHostSetting}`); - } + if (settings.host !== DEFAULT_HOST && settings.host !== newHostSetting) { logWarn( 'Host has been set in both settings() and connectFirestoreEmulator(), emulator host ' + @@ -357,6 +357,11 @@ export function connectFirestoreEmulator( firestore._setSettings(newConfig); + if (useSsl) { + void pingServer(`https://${newHostSetting}`); + updateEmulatorBanner('Firestore', true); + } + if (options.mockUserToken) { let token: string; let user: User; diff --git a/packages/functions/src/api.ts b/packages/functions/src/api.ts index 7f92cba8343..cb987035145 100644 --- a/packages/functions/src/api.ts +++ b/packages/functions/src/api.ts @@ -29,7 +29,8 @@ import { } from './service'; import { getModularInstance, - getDefaultEmulatorHostnameAndPort + getDefaultEmulatorHostnameAndPort, + updateEmulatorBanner } from '@firebase/util'; export { FunctionsError } from './error'; @@ -47,6 +48,7 @@ export function getFunctions( app: FirebaseApp = getApp(), regionOrCustomDomain: string = DEFAULT_REGION ): Functions { + updateEmulatorBanner('Functions', false); // Dependencies const functionsProvider: Provider<'functions'> = _getProvider( getModularInstance(app), diff --git a/packages/functions/src/service.ts b/packages/functions/src/service.ts index af9d8898d2e..57504a4c7a4 100644 --- a/packages/functions/src/service.ts +++ b/packages/functions/src/service.ts @@ -30,7 +30,11 @@ import { Provider } from '@firebase/component'; import { FirebaseAuthInternalName } from '@firebase/auth-interop-types'; import { MessagingInternalComponentName } from '@firebase/messaging-interop-types'; import { AppCheckInternalComponentName } from '@firebase/app-check-interop-types'; -import { isCloudWorkstation, pingServer } from '@firebase/util'; +import { + isCloudWorkstation, + pingServer, + updateEmulatorBanner +} from '@firebase/util'; export const DEFAULT_REGION = 'us-central1'; @@ -182,6 +186,7 @@ export function connectFunctionsEmulator( // Workaround to get cookies in Firebase Studio if (useSsl) { void pingServer(functionsInstance.emulatorOrigin); + updateEmulatorBanner('Functions', true); } } diff --git a/packages/storage/src/api.ts b/packages/storage/src/api.ts index 84c77ea0c8c..b164a1324c3 100644 --- a/packages/storage/src/api.ts +++ b/packages/storage/src/api.ts @@ -53,7 +53,8 @@ import { STORAGE_TYPE } from './constants'; import { EmulatorMockTokenOptions, getModularInstance, - getDefaultEmulatorHostnameAndPort + getDefaultEmulatorHostnameAndPort, + updateEmulatorBanner } from '@firebase/util'; import { StringFormat } from './implementation/string'; @@ -332,6 +333,7 @@ export function getStorage( bucketUrl?: string ): FirebaseStorage { app = getModularInstance(app); + updateEmulatorBanner('Storage', false); const storageProvider: Provider<'storage'> = _getProvider(app, STORAGE_TYPE); const storageInstance = storageProvider.getImmediate({ identifier: bucketUrl diff --git a/packages/storage/src/service.ts b/packages/storage/src/service.ts index 741dd6eaa1a..97d1407bb52 100644 --- a/packages/storage/src/service.ts +++ b/packages/storage/src/service.ts @@ -46,7 +46,8 @@ import { createMockUserToken, EmulatorMockTokenOptions, isCloudWorkstation, - pingServer + pingServer, + updateEmulatorBanner } from '@firebase/util'; import { Connection, ConnectionType } from './implementation/connection'; @@ -150,6 +151,7 @@ export function connectStorageEmulator( // Workaround to get cookies in Firebase Studio if (useSsl) { void pingServer(`https://${storage.host}`); + updateEmulatorBanner('Storage', true); } storage._isUsingEmulator = true; storage._protocol = useSsl ? 'https' : 'http'; diff --git a/packages/util/src/emulator.ts b/packages/util/src/emulator.ts index 2850b5be378..ff09940d88f 100644 --- a/packages/util/src/emulator.ts +++ b/packages/util/src/emulator.ts @@ -16,6 +16,7 @@ */ import { base64urlEncodeWithoutPadding } from './crypt'; +import { isCloudWorkstation } from './url'; // Firebase Auth tokens contain snake_case claims following the JWT standard / convention. /* eslint-disable camelcase */ @@ -140,3 +141,180 @@ export function createMockUserToken( signature ].join('.'); } + +interface EmulatorStatusMap { + [name: string]: boolean; +} +const emulatorStatus: EmulatorStatusMap = {}; + +interface EmulatorSummary { + prod: string[]; + emulator: string[]; +} + +// Checks whether any products are running on an emulator +function getEmulatorSummary(): EmulatorSummary { + const summary: EmulatorSummary = { + prod: [], + emulator: [] + }; + for (const key of Object.keys(emulatorStatus)) { + if (emulatorStatus[key]) { + summary.emulator.push(key); + } else { + summary.prod.push(key); + } + } + return summary; +} + +function getOrCreateEl(id: string): { created: boolean; element: HTMLElement } { + let parentDiv = document.getElementById(id); + let created = false; + if (!parentDiv) { + parentDiv = document.createElement('div'); + parentDiv.setAttribute('id', id); + created = true; + } + return { created, element: parentDiv }; +} + +let previouslyDismissed = false; +/** + * Updates Emulator Banner. Primarily used for Firebase Studio + * @param name + * @param isRunningEmulator + * @public + */ +export function updateEmulatorBanner( + name: string, + isRunningEmulator: boolean +): void { + if ( + typeof window === 'undefined' || + typeof document === 'undefined' || + !isCloudWorkstation(window.location.host) || + emulatorStatus[name] === isRunningEmulator || + emulatorStatus[name] || // If already set to use emulator, can't go back to prod. + previouslyDismissed + ) { + return; + } + + emulatorStatus[name] = isRunningEmulator; + + function prefixedId(id: string): string { + return `__firebase__banner__${id}`; + } + const bannerId = '__firebase__banner'; + const summary = getEmulatorSummary(); + const showError = summary.prod.length > 0; + + function tearDown(): void { + const element = document.getElementById(bannerId); + if (element) { + element.remove(); + } + } + + function setupBannerStyles(bannerEl: HTMLElement): void { + bannerEl.style.display = 'flex'; + bannerEl.style.background = '#7faaf0'; + bannerEl.style.position = 'absolute'; + bannerEl.style.bottom = '5px'; + bannerEl.style.left = '5px'; + bannerEl.style.padding = '.5em'; + bannerEl.style.borderRadius = '5px'; + bannerEl.style.alignItems = 'center'; + } + + function setupIconStyles(prependIcon: SVGElement, iconId: string): void { + prependIcon.setAttribute('width', '24'); + prependIcon.setAttribute('id', iconId); + prependIcon.setAttribute('height', '24'); + prependIcon.setAttribute('viewBox', '0 0 24 24'); + prependIcon.setAttribute('fill', 'none'); + prependIcon.style.marginLeft = '-6px'; + } + + function setupCloseBtn(): HTMLSpanElement { + const closeBtn = document.createElement('span'); + closeBtn.style.cursor = 'pointer'; + closeBtn.style.marginLeft = '16px'; + closeBtn.style.fontSize = '24px'; + closeBtn.innerHTML = ' ×'; + closeBtn.onclick = () => { + previouslyDismissed = true; + tearDown(); + }; + return closeBtn; + } + + function setupLinkStyles( + learnMoreLink: HTMLAnchorElement, + learnMoreId: string + ): void { + learnMoreLink.setAttribute('id', learnMoreId); + learnMoreLink.innerText = 'Learn more'; + learnMoreLink.href = + 'https://firebase.google.com/docs/studio/preview-apps#preview-backend'; + learnMoreLink.setAttribute('target', '__blank'); + learnMoreLink.style.paddingLeft = '5px'; + learnMoreLink.style.textDecoration = 'underline'; + } + + function setupDom(): void { + const banner = getOrCreateEl(bannerId); + const firebaseTextId = prefixedId('text'); + const firebaseText: HTMLSpanElement = + document.getElementById(firebaseTextId) || document.createElement('span'); + const learnMoreId = prefixedId('learnmore'); + const learnMoreLink: HTMLAnchorElement = + (document.getElementById(learnMoreId) as HTMLAnchorElement) || + document.createElement('a'); + const prependIconId = prefixedId('preprendIcon'); + const prependIcon: SVGElement = + (document.getElementById( + prependIconId + ) as HTMLOrSVGElement as SVGElement) || + document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + if (banner.created) { + // update styles + const bannerEl = banner.element; + setupBannerStyles(bannerEl); + setupLinkStyles(learnMoreLink, learnMoreId); + const closeBtn = setupCloseBtn(); + setupIconStyles(prependIcon, prependIconId); + bannerEl.append(prependIcon, firebaseText, learnMoreLink, closeBtn); + document.body.appendChild(bannerEl); + } + + if (showError) { + firebaseText.innerText = `Preview backend disconnected.`; + prependIcon.innerHTML = ` + + + + + + +`; + } else { + prependIcon.innerHTML = ` + + + + + + +`; + firebaseText.innerText = 'Preview backend running in this workspace.'; + } + firebaseText.setAttribute('id', firebaseTextId); + } + if (document.readyState === 'loading') { + window.addEventListener('DOMContentLoaded', setupDom); + } else { + setupDom(); + } +} diff --git a/packages/util/src/url.ts b/packages/util/src/url.ts index e41d26594c2..74a1440cb07 100644 --- a/packages/util/src/url.ts +++ b/packages/util/src/url.ts @@ -23,14 +23,35 @@ export function isCloudWorkstation(host: string): boolean { return host.endsWith('.cloudworkstations.dev'); } +const pingQueue: { [name: string]: boolean } = {}; +const MAX_PING_RETRIES = 10; + /** * Makes a fetch request to the given server. * Mostly used for forwarding cookies in Firebase Studio. * @public */ export async function pingServer(endpoint: string): Promise { - const result = await fetch(endpoint, { - credentials: 'include' - }); - return result.ok; + if (pingQueue[endpoint]) { + return Promise.resolve(false); + } + pingQueue[endpoint] = true; + function waitFor(ms: number) { + return new Promise((resolve, reject) => { + setTimeout(resolve); + }); + } + for (let i = 0; i < MAX_PING_RETRIES; i++) { + try { + await waitFor(i * 1000); + const result = await fetch(endpoint, { + credentials: 'include' + }); + if (result.ok) { + return result.ok; + } + } catch {} + } + delete pingQueue[endpoint]; + return false; }