diff --git a/packages/app/package.json b/packages/app/package.json index b888dda00eb..792a3108fdf 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -40,6 +40,7 @@ "@firebase/util": "1.4.2", "@firebase/logger": "0.3.2", "@firebase/component": "0.5.9", + "idb": "3.0.2", "tslib": "^2.1.0" }, "license": "Apache-2.0", diff --git a/packages/app/src/errors.ts b/packages/app/src/errors.ts index b8bbae5c1b8..8c9742e69de 100644 --- a/packages/app/src/errors.ts +++ b/packages/app/src/errors.ts @@ -23,7 +23,11 @@ export const enum AppError { DUPLICATE_APP = 'duplicate-app', APP_DELETED = 'app-deleted', INVALID_APP_ARGUMENT = 'invalid-app-argument', - INVALID_LOG_ARGUMENT = 'invalid-log-argument' + INVALID_LOG_ARGUMENT = 'invalid-log-argument', + STORAGE_OPEN = 'storage-open', + STORAGE_GET = 'storage-get', + STORAGE_WRITE = 'storage-set', + STORAGE_DELETE = 'storage-delete' } const ERRORS: ErrorMap = { @@ -38,7 +42,15 @@ const ERRORS: ErrorMap = { 'firebase.{$appName}() takes either no argument or a ' + 'Firebase App instance.', [AppError.INVALID_LOG_ARGUMENT]: - 'First argument to `onLog` must be null or a function.' + 'First argument to `onLog` must be null or a function.', + [AppError.STORAGE_OPEN]: + 'Error thrown when opening storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_GET]: + 'Error thrown when reading from storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_WRITE]: + 'Error thrown when writing to storage. Original error: {$originalErrorMessage}.', + [AppError.STORAGE_DELETE]: + 'Error thrown when deleting from storage. Original error: {$originalErrorMessage}.' }; interface ErrorParams { @@ -47,6 +59,10 @@ interface ErrorParams { [AppError.DUPLICATE_APP]: { appName: string }; [AppError.APP_DELETED]: { appName: string }; [AppError.INVALID_APP_ARGUMENT]: { appName: string }; + [AppError.STORAGE_OPEN]: { originalErrorMessage?: string }; + [AppError.STORAGE_GET]: { originalErrorMessage?: string }; + [AppError.STORAGE_WRITE]: { originalErrorMessage?: string }; + [AppError.STORAGE_DELETE]: { originalErrorMessage?: string }; } export const ERROR_FACTORY = new ErrorFactory( diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts new file mode 100644 index 00000000000..8d5262968fa --- /dev/null +++ b/packages/app/src/heartbeatService.test.ts @@ -0,0 +1,212 @@ +/** + * @license + * Copyright 2021 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 { expect } from 'chai'; +import '../test/setup'; +import { HeartbeatServiceImpl } from './heartbeatService'; +import { + Component, + ComponentType, + ComponentContainer +} from '@firebase/component'; +import { PlatformLoggerService } from './types'; +import { FirebaseApp } from './public-types'; +import * as firebaseUtil from '@firebase/util'; +import { SinonStub, stub, useFakeTimers } from 'sinon'; +import * as indexedDb from './indexeddb'; +import { isIndexedDBAvailable } from '@firebase/util'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'platform-logger': PlatformLoggerService; + } +} +describe('HeartbeatServiceImpl', () => { + describe('If IndexedDB has no entries', () => { + let heartbeatService: HeartbeatServiceImpl; + let clock = useFakeTimers(); + let userAgentString = 'vs1/1.2.3 vs2/2.3.4'; + let writeStub: SinonStub; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + clock = useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + /** + * NOTE: The clock is being reset between each test because of the global + * restore() in test/setup.ts. Don't assume previous clock state. + */ + it(`triggerHeartbeat() stores a heartbeat`, async () => { + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(1); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.userAgent).to.equal('vs1/1.2.3 vs2/2.3.4'); + expect(heartbeat1?.dates[0]).to.equal('1970-01-01'); + expect(writeStub).to.be.calledWith([heartbeat1]); + }); + it(`triggerHeartbeat() doesn't store another heartbeat on the same day`, async () => { + await heartbeatService.triggerHeartbeat(); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.dates.length).to.equal(1); + }); + it(`triggerHeartbeat() does store another heartbeat on a different day`, async () => { + clock.tick(24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + const heartbeat1 = heartbeatService._heartbeatsCache?.[0]; + expect(heartbeat1?.dates.length).to.equal(2); + expect(heartbeat1?.dates[1]).to.equal('1970-01-02'); + }); + it(`triggerHeartbeat() stores another entry for a different user agent`, async () => { + userAgentString = 'different/1.2.3'; + clock.tick(2 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + expect(heartbeatService._heartbeatsCache?.length).to.equal(2); + const heartbeat2 = heartbeatService._heartbeatsCache?.[1]; + expect(heartbeat2?.dates.length).to.equal(1); + expect(heartbeat2?.dates[0]).to.equal('1970-01-03'); + }); + it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { + const deleteStub = stub(heartbeatService._storage, 'deleteAll'); + const heartbeatHeaders = firebaseUtil.base64Decode( + await heartbeatService.getHeartbeatsHeader() + ); + expect(heartbeatHeaders).to.include('vs1/1.2.3 vs2/2.3.4'); + expect(heartbeatHeaders).to.include('different/1.2.3'); + expect(heartbeatHeaders).to.include('1970-01-01'); + expect(heartbeatHeaders).to.include('1970-01-02'); + expect(heartbeatHeaders).to.include('1970-01-03'); + expect(heartbeatHeaders).to.include(`"version":2`); + expect(heartbeatService._heartbeatsCache).to.equal(null); + const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); + expect(emptyHeaders).to.equal(''); + expect(deleteStub).to.be.called; + }); + }); + describe('If IndexedDB has entries', () => { + let heartbeatService: HeartbeatServiceImpl; + let clock = useFakeTimers(); + let writeStub: SinonStub; + let userAgentString = 'vs1/1.2.3 vs2/2.3.4'; + const mockIndexedDBHeartbeats = [ + { + userAgent: 'old-user-agent', + dates: ['1969-01-01', '1969-01-02'] + } + ]; + before(() => { + const container = new ComponentContainer('heartbeatTestContainer'); + container.addComponent( + new Component( + 'app', + () => + ({ + options: { appId: 'an-app-id' }, + name: 'an-app-name' + } as FirebaseApp), + ComponentType.VERSION + ) + ); + container.addComponent( + new Component( + 'platform-logger', + () => ({ getPlatformInfoString: () => userAgentString }), + ComponentType.VERSION + ) + ); + stub(indexedDb, 'readHeartbeatsFromIndexedDB').resolves({ + heartbeats: [...mockIndexedDBHeartbeats] + }); + heartbeatService = new HeartbeatServiceImpl(container); + }); + beforeEach(() => { + clock = useFakeTimers(); + writeStub = stub(heartbeatService._storage, 'overwrite'); + }); + /** + * NOTE: The clock is being reset between each test because of the global + * restore() in test/setup.ts. Don't assume previous clock state. + */ + it(`new heartbeat service reads from indexedDB cache`, async () => { + const promiseResult = await heartbeatService._heartbeatsCachePromise; + if (isIndexedDBAvailable()) { + expect(promiseResult).to.deep.equal(mockIndexedDBHeartbeats); + expect(heartbeatService._heartbeatsCache).to.deep.equal( + mockIndexedDBHeartbeats + ); + } else { + // In Node or other no-indexed-db environments it will fail the + // `canUseIndexedDb` check and return an empty array. + expect(promiseResult).to.deep.equal([]); + expect(heartbeatService._heartbeatsCache).to.deep.equal([]); + } + }); + it(`triggerHeartbeat() writes new heartbeats without removing old ones`, async () => { + userAgentString = 'different/1.2.3'; + clock.tick(3 * 24 * 60 * 60 * 1000); + await heartbeatService.triggerHeartbeat(); + if (isIndexedDBAvailable()) { + expect(writeStub).to.be.calledWith([ + ...mockIndexedDBHeartbeats, + { userAgent: 'different/1.2.3', dates: ['1970-01-04'] } + ]); + } else { + expect(writeStub).to.be.calledWith([ + { userAgent: 'different/1.2.3', dates: ['1970-01-04'] } + ]); + } + }); + it('getHeartbeatHeaders() gets stored heartbeats and clears heartbeats', async () => { + const deleteStub = stub(heartbeatService._storage, 'deleteAll'); + const heartbeatHeaders = firebaseUtil.base64Decode( + await heartbeatService.getHeartbeatsHeader() + ); + if (isIndexedDBAvailable()) { + expect(heartbeatHeaders).to.include('old-user-agent'); + expect(heartbeatHeaders).to.include('1969-01-01'); + expect(heartbeatHeaders).to.include('1969-01-02'); + } + expect(heartbeatHeaders).to.include('different/1.2.3'); + expect(heartbeatHeaders).to.include('1970-01-04'); + expect(heartbeatHeaders).to.include(`"version":2`); + expect(heartbeatService._heartbeatsCache).to.equal(null); + const emptyHeaders = await heartbeatService.getHeartbeatsHeader(); + expect(emptyHeaders).to.equal(''); + expect(deleteStub).to.be.called; + }); + }); +}); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts new file mode 100644 index 00000000000..f564781f187 --- /dev/null +++ b/packages/app/src/heartbeatService.ts @@ -0,0 +1,213 @@ +/** + * @license + * Copyright 2021 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 { ComponentContainer } from '@firebase/component'; +import { + base64Encode, + isIndexedDBAvailable, + validateIndexedDBOpenable +} from '@firebase/util'; +import { + deleteHeartbeatsFromIndexedDB, + readHeartbeatsFromIndexedDB, + writeHeartbeatsToIndexedDB +} from './indexeddb'; +import { FirebaseApp } from './public-types'; +import { + HeartbeatsByUserAgent, + HeartbeatService, + HeartbeatStorage +} from './types'; + +export class HeartbeatServiceImpl implements HeartbeatService { + /** + * The persistence layer for heartbeats + * Leave public for easier testing. + */ + _storage: HeartbeatStorageImpl; + + /** + * In-memory cache for heartbeats, used by getHeartbeatsHeader() to generate + * the header string. + * Populated from indexedDB when the controller is instantiated and should + * be kept in sync with indexedDB. + * Leave public for easier testing. + */ + _heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + + /** + * the initialization promise for populating heartbeatCache. + * If getHeartbeatsHeader() is called before the promise resolves + * (hearbeatsCache == null), it should wait for this promise + * Leave public for easier testing. + */ + _heartbeatsCachePromise: Promise; + constructor(private readonly container: ComponentContainer) { + const app = this.container.getProvider('app').getImmediate(); + this._storage = new HeartbeatStorageImpl(app); + this._heartbeatsCachePromise = this._storage.read().then(result => { + this._heartbeatsCache = result; + return result; + }); + } + + /** + * Called to report a heartbeat. The function will generate + * a HeartbeatsByUserAgent object, update heartbeatsCache, and persist it + * to IndexedDB. + * Note that we only store one heartbeat per day. So if a heartbeat for today is + * already logged, subsequent calls to this function in the same day will be ignored. + */ + async triggerHeartbeat(): Promise { + const platformLogger = this.container + .getProvider('platform-logger') + .getImmediate(); + + // This is the "Firebase user agent" string from the platform logger + // service, not the browser user agent. + const userAgent = platformLogger.getPlatformInfoString(); + const date = getUTCDateString(); + if (this._heartbeatsCache === null) { + await this._heartbeatsCachePromise; + } + let heartbeatsEntry = this._heartbeatsCache!.find( + heartbeats => heartbeats.userAgent === userAgent + ); + if (heartbeatsEntry) { + if (heartbeatsEntry.dates.includes(date)) { + // Only one per day. + return; + } else { + // Modify in-place in this.heartbeatsCache + heartbeatsEntry.dates.push(date); + } + } else { + // There is no entry for this Firebase user agent. Create one. + heartbeatsEntry = { + userAgent, + dates: [date] + }; + this._heartbeatsCache!.push(heartbeatsEntry); + } + return this._storage.overwrite(this._heartbeatsCache!); + } + + /** + * Returns a base64 encoded string which can be attached to the heartbeat-specific header directly. + * It also clears all heartbeats from memory as well as in IndexedDB. + * + * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency + */ + async getHeartbeatsHeader(): Promise { + if (this._heartbeatsCache === null) { + await this._heartbeatsCachePromise; + } + // If it's still null, it's been cleared and has not been repopulated. + if (this._heartbeatsCache === null) { + return ''; + } + const headerString = base64Encode( + JSON.stringify({ version: 2, heartbeats: this._heartbeatsCache }) + ); + this._heartbeatsCache = null; + // Do not wait for this, to reduce latency. + void this._storage.deleteAll(); + return headerString; + } +} + +function getUTCDateString(): string { + const today = new Date(); + const yearString = today.getUTCFullYear().toString(); + const month = today.getUTCMonth() + 1; + const monthString = month < 10 ? '0' + month : month.toString(); + const date = today.getUTCDate(); + const dayString = date < 10 ? '0' + date : date.toString(); + return `${yearString}-${monthString}-${dayString}`; +} + +export class HeartbeatStorageImpl implements HeartbeatStorage { + private _canUseIndexedDBPromise: Promise; + constructor(public app: FirebaseApp) { + this._canUseIndexedDBPromise = this.runIndexedDBEnvironmentCheck(); + } + async runIndexedDBEnvironmentCheck(): Promise { + if (!isIndexedDBAvailable()) { + return false; + } else { + return validateIndexedDBOpenable() + .then(() => true) + .catch(() => false); + } + } + /** + * Read all heartbeats. + */ + async read(): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return []; + } else { + const idbHeartbeatObject = await readHeartbeatsFromIndexedDB(this.app); + return idbHeartbeatObject?.heartbeats || []; + } + } + // overwrite the storage with the provided heartbeats + async overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + return writeHeartbeatsToIndexedDB(this.app, { heartbeats }); + } + } + // add heartbeats + async add(heartbeats: HeartbeatsByUserAgent[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + const existingHeartbeats = await this.read(); + return writeHeartbeatsToIndexedDB(this.app, { + heartbeats: [...existingHeartbeats, ...heartbeats] + }); + } + } + // delete heartbeats + async delete(heartbeats: HeartbeatsByUserAgent[]): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + const existingHeartbeats = await this.read(); + return writeHeartbeatsToIndexedDB(this.app, { + heartbeats: existingHeartbeats.filter( + existingHeartbeat => !heartbeats.includes(existingHeartbeat) + ) + }); + } + } + // delete all heartbeats + async deleteAll(): Promise { + const canUseIndexedDB = await this._canUseIndexedDBPromise; + if (!canUseIndexedDB) { + return; + } else { + return deleteHeartbeatsFromIndexedDB(this.app); + } + } +} diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts new file mode 100644 index 00000000000..5bdaad0b1b3 --- /dev/null +++ b/packages/app/src/indexeddb.ts @@ -0,0 +1,98 @@ +/** + * @license + * Copyright 2021 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 { DB, openDb } from 'idb'; +import { AppError, ERROR_FACTORY } from './errors'; +import { FirebaseApp } from './public-types'; +import { HeartbeatsInIndexedDB } from './types'; +const DB_NAME = 'firebase-heartbeat-database'; +const DB_VERSION = 1; +const STORE_NAME = 'firebase-heartbeat-store'; + +let dbPromise: Promise | null = null; +function getDbPromise(): Promise { + if (!dbPromise) { + dbPromise = openDb(DB_NAME, DB_VERSION, upgradeDB => { + // We don't use 'break' in this switch statement, the fall-through + // behavior is what we want, because if there are multiple versions between + // the old version and the current version, we want ALL the migrations + // that correspond to those versions to run, not only the last one. + // eslint-disable-next-line default-case + switch (upgradeDB.oldVersion) { + case 0: + upgradeDB.createObjectStore(STORE_NAME); + } + }).catch(e => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }); + }); + } + return dbPromise; +} + +export async function readHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + try { + const db = await getDbPromise(); + return db + .transaction(STORE_NAME) + .objectStore(STORE_NAME) + .get(computeKey(app)); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: e.message + }); + } +} + +export async function writeHeartbeatsToIndexedDB( + app: FirebaseApp, + heartbeatObject: HeartbeatsInIndexedDB +): Promise { + try { + const db = await getDbPromise(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + const objectStore = tx.objectStore(STORE_NAME); + await objectStore.put(heartbeatObject, computeKey(app)); + return tx.complete; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: e.message + }); + } +} + +export async function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + try { + const db = await getDbPromise(); + const tx = db.transaction(STORE_NAME, 'readwrite'); + await tx.objectStore(STORE_NAME).delete(computeKey(app)); + return tx.complete; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: e.message + }); + } +} + +function computeKey(app: FirebaseApp): string { + return `${app.name}!${app.options.appId}`; +} diff --git a/packages/app/src/internal.ts b/packages/app/src/internal.ts index d653521d535..9026a36b26a 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -106,6 +106,12 @@ export function _getProvider( app: FirebaseApp, name: T ): Provider { + const heartbeatController = (app as FirebaseAppImpl).container + .getProvider('heartbeat') + .getImmediate({ optional: true }); + if (heartbeatController) { + void heartbeatController.triggerHeartbeat(); + } return (app as FirebaseAppImpl).container.getProvider(name); } diff --git a/packages/app/src/public-types.ts b/packages/app/src/public-types.ts index 7bad697a464..f5b2ce33613 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,7 +16,11 @@ */ import { ComponentContainer } from '@firebase/component'; -import { PlatformLoggerService, VersionService } from './types'; +import { + PlatformLoggerService, + VersionService, + HeartbeatService +} from './types'; /** * A {@link @firebase/app#FirebaseApp} holds the initialization information for a collection of @@ -162,6 +166,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; + 'heartbeat': HeartbeatService; 'platform-logger': PlatformLoggerService; } } diff --git a/packages/app/src/registerCoreComponents.ts b/packages/app/src/registerCoreComponents.ts index 29ecba01f8d..744b916e4c0 100644 --- a/packages/app/src/registerCoreComponents.ts +++ b/packages/app/src/registerCoreComponents.ts @@ -20,6 +20,7 @@ import { PlatformLoggerServiceImpl } from './platformLoggerService'; import { name, version } from '../package.json'; import { _registerComponent } from './internal'; import { registerVersion } from './api'; +import { HeartbeatServiceImpl } from './heartbeatService'; export function registerCoreComponents(variant?: string): void { _registerComponent( @@ -29,6 +30,13 @@ export function registerCoreComponents(variant?: string): void { ComponentType.PRIVATE ) ); + _registerComponent( + new Component( + 'heartbeat', + container => new HeartbeatServiceImpl(container), + ComponentType.PRIVATE + ) + ); // Register `app` package. registerVersion(name, version, variant); diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 3bee24dd945..bf8005d243f 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -23,3 +23,31 @@ export interface VersionService { export interface PlatformLoggerService { getPlatformInfoString(): string; } + +export interface HeartbeatService { + triggerHeartbeat(): Promise; + getHeartbeatsHeader(): Promise; +} + +// Heartbeats grouped by the same user agent string +export interface HeartbeatsByUserAgent { + userAgent: string; + dates: string[]; +} + +export interface HeartbeatStorage { + // overwrite the storage with the provided heartbeats + overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise; + // add heartbeats + add(heartbeats: HeartbeatsByUserAgent[]): Promise; + // delete heartbeats + delete(heartbeats: HeartbeatsByUserAgent[]): Promise; + // delete all heartbeats + deleteAll(): Promise; + // read all heartbeats + read(): Promise; +} + +export interface HeartbeatsInIndexedDB { + heartbeats: HeartbeatsByUserAgent[]; +} diff --git a/packages/auth/src/api/authentication/token.test.ts b/packages/auth/src/api/authentication/token.test.ts index 529bfe3bd7c..0d486685be1 100644 --- a/packages/auth/src/api/authentication/token.test.ts +++ b/packages/auth/src/api/authentication/token.test.ts @@ -17,6 +17,7 @@ import { expect, use } from 'chai'; import chaiAsPromised from 'chai-as-promised'; +import * as sinon from 'sinon'; import { FirebaseError, getUA, querystringDecode } from '@firebase/util'; @@ -41,7 +42,10 @@ describe('requestStsToken', () => { fetch.setUp(); }); - afterEach(fetch.tearDown); + afterEach(() => { + fetch.tearDown(); + sinon.restore(); + }); it('should POST to the correct endpoint', async () => { const mock = fetch.mock(endpoint, { @@ -68,6 +72,21 @@ describe('requestStsToken', () => { ); }); + it('should set (or not set) heartbeat correctly', async () => { + const mock = fetch.mock(endpoint, { + 'access_token': 'new-access-token', + 'expires_in': '3600', + 'refresh_token': 'new-refresh-token' + }); + await requestStsToken(auth, 'old-refresh-token'); + sinon.stub(auth, '_getHeartbeatHeader').returns(Promise.resolve('heartbeat')); + await requestStsToken(auth, 'old-refresh-token'); + + // First call won't have the header, second call will. + expect(mock.calls[0].headers!.has(HttpHeader.X_FIREBASE_CLIENT)).to.be.false; + expect(mock.calls[1].headers!.get(HttpHeader.X_FIREBASE_CLIENT)).to.eq('heartbeat'); + }); + it('should set the framework in clientVersion if logged', async () => { const mock = fetch.mock(endpoint, { 'access_token': 'new-access-token', diff --git a/packages/auth/src/api/authentication/token.ts b/packages/auth/src/api/authentication/token.ts index 3966685def6..e44a53dd868 100644 --- a/packages/auth/src/api/authentication/token.ts +++ b/packages/auth/src/api/authentication/token.ts @@ -22,7 +22,8 @@ import { querystring } from '@firebase/util'; import { _getFinalTarget, _performFetchWithErrorHandling, - HttpMethod + HttpMethod, + HttpHeader } from '../index'; import { FetchProvider } from '../../core/util/fetch_provider'; import { Auth } from '../../model/public_types'; @@ -52,7 +53,7 @@ export async function requestStsToken( const response = await _performFetchWithErrorHandling( auth, {}, - () => { + async () => { const body = querystring({ 'grant_type': 'refresh_token', 'refresh_token': refreshToken @@ -65,12 +66,18 @@ export async function requestStsToken( `key=${apiKey}` ); + const heartbeat = await (auth as AuthInternal)._getHeartbeatHeader(); + const headers: HeadersInit = { + [HttpHeader.X_CLIENT_VERSION]: (auth as AuthInternal)._getSdkClientVersion(), + [HttpHeader.CONTENT_TYPE]: 'application/x-www-form-urlencoded', + }; + if (heartbeat) { + headers[HttpHeader.X_FIREBASE_CLIENT] = heartbeat; + } + return FetchProvider.fetch()(url, { method: HttpMethod.POST, - headers: { - 'X-Client-Version': (auth as AuthInternal)._getSdkClientVersion(), - 'Content-Type': 'application/x-www-form-urlencoded' - }, + headers, body }); } diff --git a/packages/auth/src/api/index.test.ts b/packages/auth/src/api/index.test.ts index fb864c87400..c2346a38732 100644 --- a/packages/auth/src/api/index.test.ts +++ b/packages/auth/src/api/index.test.ts @@ -59,6 +59,10 @@ describe('api/_performApiRequest', () => { auth = await testAuth(); }); + afterEach(() => { + sinon.restore(); + }); + context('with regular requests', () => { beforeEach(mockFetch.setUp); afterEach(mockFetch.tearDown); @@ -94,6 +98,27 @@ describe('api/_performApiRequest', () => { ); }); + it('should set the heartbeat header only if available', async () => { + const mock = mockEndpoint(Endpoint.SIGN_UP, serverResponse); + await _performApiRequest(auth, HttpMethod.POST, Endpoint.SIGN_UP, request); + sinon.stub(auth, '_getHeartbeatHeader').returns(Promise.resolve('heartbeat')); + await _performApiRequest(auth, HttpMethod.POST, Endpoint.SIGN_UP, request); + + // First call won't have the header, second call will. + expect(mock.calls[0].headers!.has(HttpHeader.X_FIREBASE_CLIENT)).to.be.false; + expect(mock.calls[1].headers!.get(HttpHeader.X_FIREBASE_CLIENT)).to.eq('heartbeat'); + }); + + it('should not set the heartbeat header on config request', async () => { + const mock = mockEndpoint(Endpoint.GET_PROJECT_CONFIG, serverResponse); + await _performApiRequest(auth, HttpMethod.POST, Endpoint.GET_PROJECT_CONFIG, request); + sinon.stub(auth, '_getHeartbeatHeader').returns(Promise.resolve('heartbeat')); + await _performApiRequest(auth, HttpMethod.POST, Endpoint.GET_PROJECT_CONFIG, request); + // First call won't have the header, second call will. + expect(mock.calls[0].headers!.has(HttpHeader.X_FIREBASE_CLIENT)).to.be.false; + expect(mock.calls[1].headers!.has(HttpHeader.X_FIREBASE_CLIENT)).to.be.false; + }); + it('should set the framework in clientVersion if logged', async () => { auth._logFramework('Mythical'); const mock = mockEndpoint(Endpoint.SIGN_UP, serverResponse); diff --git a/packages/auth/src/api/index.ts b/packages/auth/src/api/index.ts index 2547fb58d53..5cd7d0611ba 100644 --- a/packages/auth/src/api/index.ts +++ b/packages/auth/src/api/index.ts @@ -36,7 +36,8 @@ export const enum HttpMethod { export const enum HttpHeader { CONTENT_TYPE = 'Content-Type', X_FIREBASE_LOCALE = 'X-Firebase-Locale', - X_CLIENT_VERSION = 'X-Client-Version' + X_CLIENT_VERSION = 'X-Client-Version', + X_FIREBASE_CLIENT = 'X-Firebase-Client' } export const enum Endpoint { @@ -84,7 +85,8 @@ export async function _performApiRequest( request?: T, customErrorMap: Partial> = {} ): Promise { - return _performFetchWithErrorHandling(auth, customErrorMap, () => { + const authInternal = (auth as AuthInternal); + return _performFetchWithErrorHandling(auth, customErrorMap, async () => { let body = {}; let params = {}; if (request) { @@ -106,13 +108,20 @@ export async function _performApiRequest( headers.set(HttpHeader.CONTENT_TYPE, 'application/json'); headers.set( HttpHeader.X_CLIENT_VERSION, - (auth as AuthInternal)._getSdkClientVersion() + authInternal._getSdkClientVersion() ); if (auth.languageCode) { headers.set(HttpHeader.X_FIREBASE_LOCALE, auth.languageCode); } + if (!path.includes(Endpoint.GET_PROJECT_CONFIG)) { + const heartbeat = await authInternal._getHeartbeatHeader(); + if (heartbeat) { + headers.set(HttpHeader.X_FIREBASE_CLIENT, heartbeat); + } + } + return FetchProvider.fetch()( _getFinalTarget(auth, auth.config.apiHost, path, query), { diff --git a/packages/auth/src/core/auth/auth_impl.test.ts b/packages/auth/src/core/auth/auth_impl.test.ts index 72a11b04944..ab00568a8cc 100644 --- a/packages/auth/src/core/auth/auth_impl.test.ts +++ b/packages/auth/src/core/auth/auth_impl.test.ts @@ -47,13 +47,17 @@ const FAKE_APP: FirebaseApp = { automaticDataCollectionEnabled: false }; +const FAKE_HEARTBEAT_CONTROLLER = { + getHeartbeatsHeader: async () => '', +}; + describe('core/auth/auth_impl', () => { let auth: AuthInternal; let persistenceStub: sinon.SinonStubbedInstance; beforeEach(async () => { persistenceStub = sinon.stub(_getInstance(inMemoryPersistence)); - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -431,7 +435,7 @@ describe('core/auth/auth_impl', () => { }); it('prevents initialization from completing', async () => { - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, diff --git a/packages/auth/src/core/auth/auth_impl.ts b/packages/auth/src/core/auth/auth_impl.ts index e34a845669e..9d991c1c588 100644 --- a/packages/auth/src/core/auth/auth_impl.ts +++ b/packages/auth/src/core/auth/auth_impl.ts @@ -59,6 +59,10 @@ import { _getInstance } from '../util/instantiator'; import { _getUserLanguage } from '../util/navigator'; import { _getClientVersion } from '../util/version'; +interface HeartbeatService { + getHeartbeatsHeader(): Promise; +} + interface AsyncAction { (): Promise; } @@ -102,7 +106,8 @@ export class AuthImpl implements AuthInternal, _FirebaseService { constructor( public readonly app: FirebaseApp, - public readonly config: ConfigInternal + private readonly heartbeatController: HeartbeatService, + public readonly config: ConfigInternal, ) { this.name = app.name; this.clientVersion = config.sdkClientVersion; @@ -577,6 +582,9 @@ export class AuthImpl implements AuthInternal, _FirebaseService { _getSdkClientVersion(): string { return this.clientVersion; } + _getHeartbeatHeader(): Promise { + return this.heartbeatController.getHeartbeatsHeader(); + } } /** diff --git a/packages/auth/src/core/auth/register.ts b/packages/auth/src/core/auth/register.ts index abff9c25db0..a47c3455c4e 100644 --- a/packages/auth/src/core/auth/register.ts +++ b/packages/auth/src/core/auth/register.ts @@ -61,8 +61,9 @@ export function registerAuth(clientPlatform: ClientPlatform): void { _ComponentName.AUTH, (container, { options: deps }: { options?: Dependencies }) => { const app = container.getProvider('app').getImmediate()!; + const heartbeatController = container.getProvider('heartbeat').getImmediate()!; const { apiKey, authDomain } = app.options; - return (app => { + return ((app, heartbeatController) => { _assert( apiKey && !apiKey.includes(':'), AuthErrorCode.INVALID_API_KEY, @@ -82,11 +83,11 @@ export function registerAuth(clientPlatform: ClientPlatform): void { sdkClientVersion: _getClientVersion(clientPlatform) }; - const authInstance = new AuthImpl(app, config); + const authInstance = new AuthImpl(app, heartbeatController, config); _initializeAuthInstance(authInstance, deps); return authInstance; - })(app); + })(app, heartbeatController); }, ComponentType.PUBLIC ) diff --git a/packages/auth/src/model/auth.ts b/packages/auth/src/model/auth.ts index 4fbe6876eac..763cc18af33 100644 --- a/packages/auth/src/model/auth.ts +++ b/packages/auth/src/model/auth.ts @@ -82,6 +82,7 @@ export interface AuthInternal extends Auth { _logFramework(framework: string): void; _getFrameworks(): readonly string[]; _getSdkClientVersion(): string; + _getHeartbeatHeader(): Promise; readonly name: AppName; readonly config: ConfigInternal; diff --git a/packages/auth/src/platform_browser/auth.test.ts b/packages/auth/src/platform_browser/auth.test.ts index eecfc4c29f2..548406cf4c2 100644 --- a/packages/auth/src/platform_browser/auth.test.ts +++ b/packages/auth/src/platform_browser/auth.test.ts @@ -60,13 +60,17 @@ const FAKE_APP: FirebaseApp = { automaticDataCollectionEnabled: false }; +const FAKE_HEARTBEAT_CONTROLLER = { + getHeartbeatsHeader: async () => '', +}; + describe('core/auth/auth_impl', () => { let auth: AuthInternal; let persistenceStub: sinon.SinonStubbedInstance; beforeEach(async () => { persistenceStub = sinon.stub(_getInstance(inMemoryPersistence)); - const authImpl = new AuthImpl(FAKE_APP, { + const authImpl = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -132,7 +136,7 @@ describe('core/auth/initializeAuth', () => { popupRedirectResolver?: PopupRedirectResolver, authDomain = FAKE_APP.options.authDomain ): Promise { - const auth = new AuthImpl(FAKE_APP, { + const auth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, @@ -349,7 +353,7 @@ describe('core/auth/initializeAuth', () => { // Manually initialize auth to make sure no error is thrown, // since the _initializeAuthInstance function floats - const auth = new AuthImpl(FAKE_APP, { + const auth = new AuthImpl(FAKE_APP, FAKE_HEARTBEAT_CONTROLLER, { apiKey: FAKE_APP.options.apiKey!, apiHost: DefaultConfig.API_HOST, apiScheme: DefaultConfig.API_SCHEME, diff --git a/packages/auth/test/helpers/mock_auth.ts b/packages/auth/test/helpers/mock_auth.ts index 459621cd77d..b8886909c81 100644 --- a/packages/auth/test/helpers/mock_auth.ts +++ b/packages/auth/test/helpers/mock_auth.ts @@ -63,6 +63,8 @@ export async function testAuth( persistence = new MockPersistenceLayer() ): Promise { const auth: TestAuth = new AuthImpl(FAKE_APP, { + getHeartbeatsHeader: () => Promise.resolve('') + }, { apiKey: TEST_KEY, authDomain: TEST_AUTH_DOMAIN, apiHost: TEST_HOST,