From 9e9858c7d54fbe23968a75d03ae5581e36c7ff6e Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Tue, 9 Nov 2021 16:07:33 -0800 Subject: [PATCH 01/11] initial implementation --- packages/app/src/errors.ts | 20 ++- packages/app/src/heartbeatService.ts | 161 +++++++++++++++++++ packages/app/src/indexeddb.ts | 170 +++++++++++++++++++++ packages/app/src/internal.ts | 4 + packages/app/src/public-types.ts | 2 + packages/app/src/registerCoreComponents.ts | 8 + packages/app/src/types.ts | 58 +++++++ 7 files changed, 421 insertions(+), 2 deletions(-) create mode 100644 packages/app/src/heartbeatService.ts create mode 100644 packages/app/src/indexeddb.ts 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.ts b/packages/app/src/heartbeatService.ts new file mode 100644 index 00000000000..6e8daf6296e --- /dev/null +++ b/packages/app/src/heartbeatService.ts @@ -0,0 +1,161 @@ +/** + * @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 { FirebaseApp } from '../dist/app/src'; +import { + deleteHeartbeatsFromIndexedDB, + readHeartbeatsFromIndexedDB, + writeHeartbeatsToIndexedDB +} from './indexeddb'; +import { + HeartbeatsByUserAgent, + HeartbeatService, + HeartbeatStorage +} from './types'; + +export class HeartbeatServiceImpl implements HeartbeatService { + storage: HeartbeatStorageImpl; + heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + 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)); + } + async triggerHeartbeat(): Promise { + const platformLogger = this.container + .getProvider('platform-logger') + .getImmediate(); + const userAgent = platformLogger.getPlatformInfoString(); + const date = getDateString(); + if (!this.heartbeatsCache) { + await this.heartbeatsCachePromise; + } + let heartbeatsEntry = this.heartbeatsCache!.find( + heartbeats => heartbeats.userAgent === userAgent + ); + if (heartbeatsEntry) { + if (heartbeatsEntry.dates.includes(date)) { + return; + } else { + heartbeatsEntry.dates.push(date); + } + } else { + heartbeatsEntry = { + userAgent, + dates: [date] + }; + } + return this.storage.overwrite([]); + } + async getHeartbeatsHeader(): Promise { + if (!this.heartbeatsCache) { + await this.heartbeatsCachePromise; + } + return base64Encode(JSON.stringify(this.heartbeatsCache!)); + } +} + +function getDateString(): string { + const today = new Date(); + const yearString = today.getFullYear().toString(); + const month = today.getMonth() + 1; + const monthString = month < 10 ? '0' + month : month.toString(); + const date = today.getDate(); + 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..e6038dad5da --- /dev/null +++ b/packages/app/src/indexeddb.ts @@ -0,0 +1,170 @@ +/** + * @license + * Copyright 2020 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 { FirebaseApp } from '@firebase/app'; +import { AppError, ERROR_FACTORY } from './errors'; +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) { + return dbPromise; + } + + dbPromise = new Promise((resolve, reject) => { + try { + const request = indexedDB.open(DB_NAME, DB_VERSION); + + request.onsuccess = event => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + + request.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + + // 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 (event.oldVersion) { + case 0: + db.createObjectStore(STORE_NAME, { + keyPath: 'compositeKey' + }); + } + }; + } catch (e) { + reject( + ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }) + ); + } + }); + + return dbPromise; +} + +export function readHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + return read(computeKey(app)) as Promise; +} + +export function writeHeartbeatsToIndexedDB( + app: FirebaseApp, + heartbeatObject: HeartbeatsInIndexedDB +): Promise { + return write(computeKey(app), heartbeatObject); +} + +export function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { + return deleteEntry(computeKey(app)); +} + +async function write(key: string, value: unknown): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readwrite'); + const store = transaction.objectStore(STORE_NAME); + const request = store.put({ + compositeKey: key, + value + }); + + return new Promise((resolve, reject) => { + request.onsuccess = _event => { + resolve(); + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +async function read(key: string): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = event => { + const result = (event.target as IDBRequest).result; + + if (result) { + resolve(result.value); + } else { + resolve(undefined); + } + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: (event.target as IDBRequest).error?.message + }) + ); + }; + }); +} + +async function deleteEntry(key: string): Promise { + const db = await getDBPromise(); + + const transaction = db.transaction(STORE_NAME, 'readonly'); + const store = transaction.objectStore(STORE_NAME); + const request = store.delete(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + + transaction.onerror = event => { + reject( + ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: (event.target as IDBRequest).error?.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..1ab44e0701e 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -106,6 +106,10 @@ export function _getProvider( app: FirebaseApp, name: T ): Provider { + const heartbeatController = (app as FirebaseAppImpl).container + .getProvider('heartbeat') + .getImmediate(); + 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..672b5cfdb8a 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,6 +16,7 @@ */ import { ComponentContainer } from '@firebase/component'; +import { HeartbeatServiceImpl } from './heartbeatService'; import { PlatformLoggerService, VersionService } from './types'; /** @@ -162,6 +163,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; + 'heartbeat': HeartbeatServiceImpl; '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..07088772e28 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -23,3 +23,61 @@ export interface VersionService { export interface PlatformLoggerService { getPlatformInfoString(): string; } + +export interface HeartbeatService { + // The persistence layer for heartbeats + storage: HeartbeatStorage; + /** + * 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 + */ + heartbeatsCache: HeartbeatsByUserAgent[] | null; + + /** + * the initialization promise for populating heartbeatCache. + * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise + */ + heartbeatsCachePromise: Promise; + + + /** + * 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, the subsequent calls to this function in the same day will be ignored. + */ + triggerHeartbeat(): Promise + + /** + * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) 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 the indexedDB to reduce latency + */ + 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[] +} \ No newline at end of file From f7b15270118d304abc209b9c23ee7ee1c3d5a5d8 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 11 Nov 2021 09:39:29 -0800 Subject: [PATCH 02/11] Fix so it will build --- packages/app/src/heartbeatService.ts | 2 +- packages/app/src/indexeddb.ts | 4 +--- packages/app/src/public-types.ts | 9 +++++--- packages/app/src/types.ts | 34 +++++++++++++--------------- 4 files changed, 24 insertions(+), 25 deletions(-) diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 6e8daf6296e..a192f98ab06 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -21,12 +21,12 @@ import { isIndexedDBAvailable, validateIndexedDBOpenable } from '@firebase/util'; -import { FirebaseApp } from '../dist/app/src'; import { deleteHeartbeatsFromIndexedDB, readHeartbeatsFromIndexedDB, writeHeartbeatsToIndexedDB } from './indexeddb'; +import { FirebaseApp } from './public-types'; import { HeartbeatsByUserAgent, HeartbeatService, diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index e6038dad5da..d0e7a8a568d 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -84,9 +84,7 @@ export function writeHeartbeatsToIndexedDB( return write(computeKey(app), heartbeatObject); } -export function deleteHeartbeatsFromIndexedDB( - app: FirebaseApp -): Promise { +export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { return deleteEntry(computeKey(app)); } diff --git a/packages/app/src/public-types.ts b/packages/app/src/public-types.ts index 672b5cfdb8a..f5b2ce33613 100644 --- a/packages/app/src/public-types.ts +++ b/packages/app/src/public-types.ts @@ -16,8 +16,11 @@ */ import { ComponentContainer } from '@firebase/component'; -import { HeartbeatServiceImpl } from './heartbeatService'; -import { PlatformLoggerService, VersionService } from './types'; +import { + PlatformLoggerService, + VersionService, + HeartbeatService +} from './types'; /** * A {@link @firebase/app#FirebaseApp} holds the initialization information for a collection of @@ -163,7 +166,7 @@ declare module '@firebase/component' { interface NameServiceMapping { 'app': FirebaseApp; 'app-version': VersionService; - 'heartbeat': HeartbeatServiceImpl; + 'heartbeat': HeartbeatService; 'platform-logger': PlatformLoggerService; } } diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index 07088772e28..b217572792d 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -27,27 +27,26 @@ export interface PlatformLoggerService { export interface HeartbeatService { // The persistence layer for heartbeats storage: HeartbeatStorage; - /** - * 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 - */ + /** + * 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 + */ heartbeatsCache: HeartbeatsByUserAgent[] | null; - + /** * the initialization promise for populating heartbeatCache. * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise */ - heartbeatsCachePromise: Promise; - + heartbeatsCachePromise: Promise; /** - * Called to report a heartbeat. The function will generate - * a HeartbeatsByUserAgent object, update heartbeatsCache, and persist it + * 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, the subsequent calls to this function in the same day will be ignored. */ - triggerHeartbeat(): Promise + triggerHeartbeat(): Promise; /** * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) header directly. @@ -56,7 +55,6 @@ export interface HeartbeatService { * NOTE: It will read heartbeats from the heartbeatsCache, instead of from the indexedDB to reduce latency */ getHeartbeatsHeader(): Promise; - } // Heartbeats grouped by the same user agent string @@ -67,17 +65,17 @@ export interface HeartbeatsByUserAgent { export interface HeartbeatStorage { // overwrite the storage with the provided heartbeats - overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise + overwrite(heartbeats: HeartbeatsByUserAgent[]): Promise; // add heartbeats - add(heartbeats: HeartbeatsByUserAgent[]): Promise + add(heartbeats: HeartbeatsByUserAgent[]): Promise; // delete heartbeats - delete(heartbeats: HeartbeatsByUserAgent[]): Promise + delete(heartbeats: HeartbeatsByUserAgent[]): Promise; // delete all heartbeats - deleteAll(): Promise + deleteAll(): Promise; // read all heartbeats - read(): Promise + read(): Promise; } export interface HeartbeatsInIndexedDB { - heartbeats: HeartbeatsByUserAgent[] -} \ No newline at end of file + heartbeats: HeartbeatsByUserAgent[]; +} From e0322fe4ccbb27a078776855db26fcb4760081dc Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 11 Nov 2021 16:20:36 -0800 Subject: [PATCH 03/11] Move based indexedDB operations to util --- packages/app/src/heartbeatService.ts | 75 +++++++++--- packages/app/src/indexeddb.ts | 165 +++++++-------------------- packages/app/src/types.ts | 28 ----- packages/util/index.node.ts | 3 + packages/util/index.ts | 1 + packages/util/src/indexeddb.ts | 132 +++++++++++++++++++++ 6 files changed, 232 insertions(+), 172 deletions(-) create mode 100644 packages/util/src/indexeddb.ts diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index a192f98ab06..1cb99de9148 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -34,56 +34,95 @@ import { } from './types'; export class HeartbeatServiceImpl implements HeartbeatService { - storage: HeartbeatStorageImpl; - heartbeatsCache: HeartbeatsByUserAgent[] | null = null; - heartbeatsCachePromise: Promise; + // The persistence layer for heartbeats + private _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. + */ + private _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 + */ + private _heartbeatsCachePromise: Promise; constructor(private readonly container: ComponentContainer) { const app = this.container.getProvider('app').getImmediate(); - this.storage = new HeartbeatStorageImpl(app); - this.heartbeatsCachePromise = this.storage + this._storage = new HeartbeatStorageImpl(app); + this._heartbeatsCachePromise = this._storage .read() - .then(result => (this.heartbeatsCache = result)); + .then(result => (this._heartbeatsCache = 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 = getDateString(); - if (!this.heartbeatsCache) { - await this.heartbeatsCachePromise; + const date = getUTCDateString(); + if (!this._heartbeatsCache) { + await this._heartbeatsCachePromise; } - let heartbeatsEntry = this.heartbeatsCache!.find( + 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([]); + 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) { - await this.heartbeatsCachePromise; + if (!this._heartbeatsCache) { + await this._heartbeatsCachePromise; } - return base64Encode(JSON.stringify(this.heartbeatsCache!)); + const headerString = base64Encode(JSON.stringify(this._heartbeatsCache!)); + this._heartbeatsCache = null; + // Do not wait for this, to reduce latency. + void this._storage.deleteAll(); + return headerString; } } -function getDateString(): string { +function getUTCDateString(): string { const today = new Date(); - const yearString = today.getFullYear().toString(); - const month = today.getMonth() + 1; + const yearString = today.getUTCFullYear().toString(); + const month = today.getUTCMonth() + 1; const monthString = month < 10 ? '0' + month : month.toString(); - const date = today.getDate(); + const date = today.getUTCDate(); const dayString = date < 10 ? '0' + date : date.toString(); return `${yearString}-${monthString}-${dayString}`; } diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index d0e7a8a568d..5ad072a3389 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2020 Google LLC + * 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. @@ -16,151 +16,64 @@ */ import { FirebaseApp } from '@firebase/app'; +import { + IndexedDbDatabaseService, + write, + read, + deleteEntry +} from '@firebase/util'; import { AppError, ERROR_FACTORY } from './errors'; 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) { - return dbPromise; +const dbService = new IndexedDbDatabaseService( + DB_NAME, + STORE_NAME, + DB_VERSION, + error => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: error.message + }); } - - dbPromise = new Promise((resolve, reject) => { - try { - const request = indexedDB.open(DB_NAME, DB_VERSION); - - request.onsuccess = event => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - - request.onupgradeneeded = event => { - const db = (event.target as IDBOpenDBRequest).result; - - // 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 (event.oldVersion) { - case 0: - db.createObjectStore(STORE_NAME, { - keyPath: 'compositeKey' - }); - } - }; - } catch (e) { - reject( - ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: e.message - }) - ); - } - }); - - return dbPromise; -} +); export function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { - return read(computeKey(app)) as Promise; + try { + return read(dbService, computeKey(app)) as Promise< + HeartbeatsInIndexedDB | undefined + >; + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_GET, { + originalErrorMessage: e.message + }); + } } export function writeHeartbeatsToIndexedDB( app: FirebaseApp, heartbeatObject: HeartbeatsInIndexedDB ): Promise { - return write(computeKey(app), heartbeatObject); + try { + return write(dbService, computeKey(app), heartbeatObject); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { + originalErrorMessage: e.message + }); + } } export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { - return deleteEntry(computeKey(app)); -} - -async function write(key: string, value: unknown): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readwrite'); - const store = transaction.objectStore(STORE_NAME); - const request = store.put({ - compositeKey: key, - value - }); - - return new Promise((resolve, reject) => { - request.onsuccess = _event => { - resolve(); - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_WRITE, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); -} - -async function read(key: string): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.get(key); - - return new Promise((resolve, reject) => { - request.onsuccess = event => { - const result = (event.target as IDBRequest).result; - - if (result) { - resolve(result.value); - } else { - resolve(undefined); - } - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_GET, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); -} - -async function deleteEntry(key: string): Promise { - const db = await getDBPromise(); - - const transaction = db.transaction(STORE_NAME, 'readonly'); - const store = transaction.objectStore(STORE_NAME); - const request = store.delete(key); - - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve(); - }; - - transaction.onerror = event => { - reject( - ERROR_FACTORY.create(AppError.STORAGE_DELETE, { - originalErrorMessage: (event.target as IDBRequest).error?.message - }) - ); - }; - }); + try { + return deleteEntry(dbService, computeKey(app)); + } catch (e) { + throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { + originalErrorMessage: e.message + }); + } } function computeKey(app: FirebaseApp): string { diff --git a/packages/app/src/types.ts b/packages/app/src/types.ts index b217572792d..bf8005d243f 100644 --- a/packages/app/src/types.ts +++ b/packages/app/src/types.ts @@ -25,35 +25,7 @@ export interface PlatformLoggerService { } export interface HeartbeatService { - // The persistence layer for heartbeats - storage: HeartbeatStorage; - /** - * 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 - */ - heartbeatsCache: HeartbeatsByUserAgent[] | null; - - /** - * the initialization promise for populating heartbeatCache. - * If getHeartbeatsHeader() is called before the promise resolves (hearbeatsCache == null), it should wait for this promise - */ - heartbeatsCachePromise: Promise; - - /** - * 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, the subsequent calls to this function in the same day will be ignored. - */ triggerHeartbeat(): Promise; - - /** - * Returns a based64 encoded string which can be attached to the X-firebase-client(TBD) 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 the indexedDB to reduce latency - */ getHeartbeatsHeader(): Promise; } diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index 8dace3b8e1e..c9d0059ce5c 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -39,3 +39,6 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +// IndexedDB isn't available in Node but we don't want an import error importing +// these methods from util. +export * from './src/indexeddb'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 00d661734b8..0cf518fbd81 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -34,3 +34,4 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; +export * from './src/indexeddb'; diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts new file mode 100644 index 00000000000..d0bacda5c8e --- /dev/null +++ b/packages/util/src/indexeddb.ts @@ -0,0 +1,132 @@ +/** + * @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. + */ + +export class IndexedDbDatabaseService { + dbPromise: Promise; + constructor( + public dbName: string, + public storeName: string, + public dbVersion: number, + errorHandler: (error: Error) => void + ) { + this.dbPromise = new Promise((resolve, reject) => { + try { + const request = indexedDB.open(this.dbName, this.dbVersion); + + request.onsuccess = event => { + resolve((event.target as IDBOpenDBRequest).result); + }; + + request.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + + request.onupgradeneeded = event => { + const db = (event.target as IDBOpenDBRequest).result; + + // 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 (event.oldVersion) { + case 0: + db.createObjectStore(this.storeName, { + keyPath: 'compositeKey' + }); + } + }; + } catch (e) { + reject(e.message); + } + }); + this.dbPromise.catch(errorHandler); + } +} + +export async function write( + dbService: IndexedDbDatabaseService, + key: string, + value: unknown +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readwrite'); + const store = transaction.objectStore(dbService.storeName); + const request = store.put({ + compositeKey: key, + value + }); + + return new Promise((resolve, reject) => { + request.onsuccess = _event => { + resolve(); + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} + +export async function read( + dbService: IndexedDbDatabaseService, + key: string +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readonly'); + const store = transaction.objectStore(dbService.storeName); + const request = store.get(key); + + return new Promise((resolve, reject) => { + request.onsuccess = event => { + const result = (event.target as IDBRequest).result; + + if (result) { + resolve(result.value); + } else { + resolve(undefined); + } + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} + +export async function deleteEntry( + dbService: IndexedDbDatabaseService, + key: string +): Promise { + const db = await dbService.dbPromise; + + const transaction = db.transaction(dbService.storeName, 'readonly'); + const store = transaction.objectStore(dbService.storeName); + const request = store.delete(key); + + return new Promise((resolve, reject) => { + request.onsuccess = () => { + resolve(); + }; + + transaction.onerror = event => { + reject((event.target as IDBRequest).error?.message); + }; + }); +} From 45af5db768eefdac678e658fb88e8d9fec3e5d11 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 12 Nov 2021 09:30:56 -0800 Subject: [PATCH 04/11] Add tests --- packages/app/src/heartbeatService.test.ts | 59 +++++++++++++++++++++++ packages/app/src/indexeddb.ts | 2 +- packages/app/src/internal.ts | 6 ++- 3 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 packages/app/src/heartbeatService.test.ts diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts new file mode 100644 index 00000000000..2661961cddc --- /dev/null +++ b/packages/app/src/heartbeatService.test.ts @@ -0,0 +1,59 @@ +/** + * @license + * Copyright 2017 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 { base64Decode } from '@firebase/util'; + +declare module '@firebase/component' { + interface NameServiceMapping { + 'platform-logger': PlatformLoggerService; + } +} + +describe('Heartbeat Service', () => { + it(`logs a heartbeat`, async () => { + 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: () => 'vs1/1.2.3 vs2/2.3.4' }), + ComponentType.VERSION + ) + ); + const heartbeatService = new HeartbeatServiceImpl(container); + await heartbeatService.triggerHeartbeat(); + const heartbeatHeaders = base64Decode(await heartbeatService.getHeartbeatsHeader()); + expect(heartbeatHeaders).to.include('vs1/1.2.3'); + expect(heartbeatHeaders).to.include('2021-'); + }); +}); diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 5ad072a3389..17136967d45 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -15,7 +15,6 @@ * limitations under the License. */ -import { FirebaseApp } from '@firebase/app'; import { IndexedDbDatabaseService, write, @@ -23,6 +22,7 @@ import { deleteEntry } from '@firebase/util'; 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; diff --git a/packages/app/src/internal.ts b/packages/app/src/internal.ts index 1ab44e0701e..9026a36b26a 100644 --- a/packages/app/src/internal.ts +++ b/packages/app/src/internal.ts @@ -108,8 +108,10 @@ export function _getProvider( ): Provider { const heartbeatController = (app as FirebaseAppImpl).container .getProvider('heartbeat') - .getImmediate(); - void heartbeatController.triggerHeartbeat(); + .getImmediate({ optional: true }); + if (heartbeatController) { + void heartbeatController.triggerHeartbeat(); + } return (app as FirebaseAppImpl).container.getProvider(name); } From cb8e308b46db8765a4b44b40adff15eb30db2ec0 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Fri, 12 Nov 2021 12:05:14 -0800 Subject: [PATCH 05/11] add tests --- packages/app/src/heartbeatService.test.ts | 199 +++++++++++++++++++--- packages/app/src/heartbeatService.ts | 36 ++-- packages/app/src/indexeddb.ts | 12 +- packages/util/src/indexeddb.ts | 8 +- packages/util/test/indexeddb.test.ts | 43 +++++ packages/util/test/object.test.ts | 2 +- 6 files changed, 253 insertions(+), 47 deletions(-) create mode 100644 packages/util/test/indexeddb.test.ts diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 2661961cddc..e470785c02b 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -25,35 +25,186 @@ import { } from '@firebase/component'; import { PlatformLoggerService } from './types'; import { FirebaseApp } from './public-types'; -import { base64Decode } from '@firebase/util'; +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('Heartbeat Service', () => { - it(`logs a heartbeat`, async () => { - 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: () => 'vs1/1.2.3 vs2/2.3.4' }), - ComponentType.VERSION - ) - ); - const heartbeatService = new HeartbeatServiceImpl(container); - await heartbeatService.triggerHeartbeat(); - const heartbeatHeaders = base64Decode(await heartbeatService.getHeartbeatsHeader()); - expect(heartbeatHeaders).to.include('vs1/1.2.3'); - expect(heartbeatHeaders).to.include('2021-'); +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(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(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 index 1cb99de9148..8e65f6907fd 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -34,28 +34,35 @@ import { } from './types'; export class HeartbeatServiceImpl implements HeartbeatService { - // The persistence layer for heartbeats - private _storage: HeartbeatStorageImpl; + /** + * The persistence layer for heartbeats + * Leave public for easier testing. + */ + _storage: HeartbeatStorageImpl; /** - * in-memory cache for heartbeats, used by getHeartbeatsHeader() to generate + * 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. */ - private _heartbeatsCache: HeartbeatsByUserAgent[] | null = null; + _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 + * If getHeartbeatsHeader() is called before the promise resolves + * (hearbeatsCache == null), it should wait for this promise + * Leave public for easier testing. */ - private _heartbeatsCachePromise: Promise; + _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)); + this._heartbeatsCachePromise = this._storage.read().then(result => { + this._heartbeatsCache = result; + return result; + }); } /** @@ -74,7 +81,7 @@ export class HeartbeatServiceImpl implements HeartbeatService { // service, not the browser user agent. const userAgent = platformLogger.getPlatformInfoString(); const date = getUTCDateString(); - if (!this._heartbeatsCache) { + if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } let heartbeatsEntry = this._heartbeatsCache!.find( @@ -106,12 +113,17 @@ export class HeartbeatServiceImpl implements HeartbeatService { * NOTE: It will read heartbeats from the heartbeatsCache, instead of from indexedDB to reduce latency */ async getHeartbeatsHeader(): Promise { - if (!this._heartbeatsCache) { + if (this._heartbeatsCache === null) { await this._heartbeatsCachePromise; } - const headerString = base64Encode(JSON.stringify(this._heartbeatsCache!)); + // If it's still null, it's been cleared and has not been repopulated. + if (this._heartbeatsCache === null) { + return ''; + } + const headerString = base64Encode(JSON.stringify(this._heartbeatsCache)); this._heartbeatsCache = null; // Do not wait for this, to reduce latency. + console.log('calling deleteAll'); void this._storage.deleteAll(); return headerString; } diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 17136967d45..3e724c341c9 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -17,9 +17,9 @@ import { IndexedDbDatabaseService, - write, - read, - deleteEntry + idbWrite, + idbRead, + idbDelete } from '@firebase/util'; import { AppError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from './public-types'; @@ -43,7 +43,7 @@ export function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { try { - return read(dbService, computeKey(app)) as Promise< + return idbRead(dbService, computeKey(app)) as Promise< HeartbeatsInIndexedDB | undefined >; } catch (e) { @@ -58,7 +58,7 @@ export function writeHeartbeatsToIndexedDB( heartbeatObject: HeartbeatsInIndexedDB ): Promise { try { - return write(dbService, computeKey(app), heartbeatObject); + return idbWrite(dbService, computeKey(app), heartbeatObject); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_WRITE, { originalErrorMessage: e.message @@ -68,7 +68,7 @@ export function writeHeartbeatsToIndexedDB( export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { try { - return deleteEntry(dbService, computeKey(app)); + return idbDelete(dbService, computeKey(app)); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_DELETE, { originalErrorMessage: e.message diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts index d0bacda5c8e..882ecb7bd4a 100644 --- a/packages/util/src/indexeddb.ts +++ b/packages/util/src/indexeddb.ts @@ -58,7 +58,7 @@ export class IndexedDbDatabaseService { } } -export async function write( +export async function idbWrite( dbService: IndexedDbDatabaseService, key: string, value: unknown @@ -83,7 +83,7 @@ export async function write( }); } -export async function read( +export async function idbRead( dbService: IndexedDbDatabaseService, key: string ): Promise { @@ -110,13 +110,13 @@ export async function read( }); } -export async function deleteEntry( +export async function idbDelete( dbService: IndexedDbDatabaseService, key: string ): Promise { const db = await dbService.dbPromise; - const transaction = db.transaction(dbService.storeName, 'readonly'); + const transaction = db.transaction(dbService.storeName, 'readwrite'); const store = transaction.objectStore(dbService.storeName); const request = store.delete(key); diff --git a/packages/util/test/indexeddb.test.ts b/packages/util/test/indexeddb.test.ts new file mode 100644 index 00000000000..334d46710cc --- /dev/null +++ b/packages/util/test/indexeddb.test.ts @@ -0,0 +1,43 @@ +/** + * @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 { isIndexedDBAvailable } from '../src/environment'; +import { + IndexedDbDatabaseService, + idbRead, + idbWrite, + idbDelete +} from '../src/indexeddb'; + +describe('IndexedDbDatabaseService', () => { + it('Can write, read, and delete', async () => { + if (!isIndexedDBAvailable()) { + // skip if in Node + return; + } + const idb = new IndexedDbDatabaseService('test-db', 'test-store', 1, e => { + console.error(e); + }); + await idbWrite(idb, 'a-key', { data: 'abcd' }); + const result = await idbRead(idb, 'a-key'); + expect((result as any).data).to.equal('abcd'); + await idbDelete(idb, 'a-key'); + const resultAfterDelete = await idbRead(idb, 'a-key'); + expect(resultAfterDelete).to.not.exist; + }); +}); diff --git a/packages/util/test/object.test.ts b/packages/util/test/object.test.ts index d6f86209a83..a3691aacc8e 100644 --- a/packages/util/test/object.test.ts +++ b/packages/util/test/object.test.ts @@ -19,7 +19,7 @@ import { expect } from 'chai'; import { deepEqual } from '../src/obj'; // eslint-disable-next-line no-restricted-properties -describe.only('deepEqual()', () => { +describe('deepEqual()', () => { it('returns true for comparing empty objects', () => { expect(deepEqual({}, {})).to.be.true; }); From bd55e7be959d3186590b607bac385d0777fe4432 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Mon, 29 Nov 2021 14:13:27 -0800 Subject: [PATCH 06/11] Fix year --- packages/app/src/heartbeatService.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index e470785c02b..3ea33e22055 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -1,6 +1,6 @@ /** * @license - * Copyright 2017 Google LLC + * 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. From de237828bb5ee53f43e5b48f83c381bff9a551cd Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 10:02:06 -0800 Subject: [PATCH 07/11] use idb --- packages/app/package.json | 1 + packages/app/src/indexeddb.ts | 52 +++++++------ packages/util/index.node.ts | 3 - packages/util/index.ts | 1 - packages/util/src/indexeddb.ts | 132 --------------------------------- 5 files changed, 31 insertions(+), 158 deletions(-) delete mode 100644 packages/util/src/indexeddb.ts 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/indexeddb.ts b/packages/app/src/indexeddb.ts index 3e724c341c9..0bffa4ff50b 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -15,12 +15,7 @@ * limitations under the License. */ -import { - IndexedDbDatabaseService, - idbWrite, - idbRead, - idbDelete -} from '@firebase/util'; +import { DB, openDb } from 'idb'; import { AppError, ERROR_FACTORY } from './errors'; import { FirebaseApp } from './public-types'; import { HeartbeatsInIndexedDB } from './types'; @@ -28,24 +23,30 @@ const DB_NAME = 'firebase-heartbeat-database'; const DB_VERSION = 1; const STORE_NAME = 'firebase-heartbeat-store'; -const dbService = new IndexedDbDatabaseService( - DB_NAME, - STORE_NAME, - DB_VERSION, - error => { - throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { - originalErrorMessage: error.message +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); + } }); } -); + return dbPromise; +} -export function readHeartbeatsFromIndexedDB( +export async function readHeartbeatsFromIndexedDB( app: FirebaseApp ): Promise { try { - return idbRead(dbService, computeKey(app)) as Promise< - HeartbeatsInIndexedDB | undefined - >; + 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 @@ -53,12 +54,16 @@ export function readHeartbeatsFromIndexedDB( } } -export function writeHeartbeatsToIndexedDB( +export async function writeHeartbeatsToIndexedDB( app: FirebaseApp, heartbeatObject: HeartbeatsInIndexedDB ): Promise { try { - return idbWrite(dbService, computeKey(app), heartbeatObject); + 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 @@ -66,9 +71,12 @@ export function writeHeartbeatsToIndexedDB( } } -export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { +export async function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { try { - return idbDelete(dbService, computeKey(app)); + 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 diff --git a/packages/util/index.node.ts b/packages/util/index.node.ts index c9d0059ce5c..8dace3b8e1e 100644 --- a/packages/util/index.node.ts +++ b/packages/util/index.node.ts @@ -39,6 +39,3 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; -// IndexedDB isn't available in Node but we don't want an import error importing -// these methods from util. -export * from './src/indexeddb'; diff --git a/packages/util/index.ts b/packages/util/index.ts index 0cf518fbd81..00d661734b8 100644 --- a/packages/util/index.ts +++ b/packages/util/index.ts @@ -34,4 +34,3 @@ export * from './src/utf8'; export * from './src/exponential_backoff'; export * from './src/formatters'; export * from './src/compat'; -export * from './src/indexeddb'; diff --git a/packages/util/src/indexeddb.ts b/packages/util/src/indexeddb.ts deleted file mode 100644 index 882ecb7bd4a..00000000000 --- a/packages/util/src/indexeddb.ts +++ /dev/null @@ -1,132 +0,0 @@ -/** - * @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. - */ - -export class IndexedDbDatabaseService { - dbPromise: Promise; - constructor( - public dbName: string, - public storeName: string, - public dbVersion: number, - errorHandler: (error: Error) => void - ) { - this.dbPromise = new Promise((resolve, reject) => { - try { - const request = indexedDB.open(this.dbName, this.dbVersion); - - request.onsuccess = event => { - resolve((event.target as IDBOpenDBRequest).result); - }; - - request.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - - request.onupgradeneeded = event => { - const db = (event.target as IDBOpenDBRequest).result; - - // 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 (event.oldVersion) { - case 0: - db.createObjectStore(this.storeName, { - keyPath: 'compositeKey' - }); - } - }; - } catch (e) { - reject(e.message); - } - }); - this.dbPromise.catch(errorHandler); - } -} - -export async function idbWrite( - dbService: IndexedDbDatabaseService, - key: string, - value: unknown -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readwrite'); - const store = transaction.objectStore(dbService.storeName); - const request = store.put({ - compositeKey: key, - value - }); - - return new Promise((resolve, reject) => { - request.onsuccess = _event => { - resolve(); - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} - -export async function idbRead( - dbService: IndexedDbDatabaseService, - key: string -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readonly'); - const store = transaction.objectStore(dbService.storeName); - const request = store.get(key); - - return new Promise((resolve, reject) => { - request.onsuccess = event => { - const result = (event.target as IDBRequest).result; - - if (result) { - resolve(result.value); - } else { - resolve(undefined); - } - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} - -export async function idbDelete( - dbService: IndexedDbDatabaseService, - key: string -): Promise { - const db = await dbService.dbPromise; - - const transaction = db.transaction(dbService.storeName, 'readwrite'); - const store = transaction.objectStore(dbService.storeName); - const request = store.delete(key); - - return new Promise((resolve, reject) => { - request.onsuccess = () => { - resolve(); - }; - - transaction.onerror = event => { - reject((event.target as IDBRequest).error?.message); - }; - }); -} From 6960753cb45f596032c530cc68fa912b839333fa Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:08:22 -0800 Subject: [PATCH 08/11] Add version to payload --- packages/app/src/heartbeatService.test.ts | 2 ++ packages/app/src/heartbeatService.ts | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app/src/heartbeatService.test.ts b/packages/app/src/heartbeatService.test.ts index 3ea33e22055..8d5262968fa 100644 --- a/packages/app/src/heartbeatService.test.ts +++ b/packages/app/src/heartbeatService.test.ts @@ -110,6 +110,7 @@ describe('HeartbeatServiceImpl', () => { 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(''); @@ -201,6 +202,7 @@ describe('HeartbeatServiceImpl', () => { } 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(''); diff --git a/packages/app/src/heartbeatService.ts b/packages/app/src/heartbeatService.ts index 8e65f6907fd..f564781f187 100644 --- a/packages/app/src/heartbeatService.ts +++ b/packages/app/src/heartbeatService.ts @@ -120,10 +120,11 @@ export class HeartbeatServiceImpl implements HeartbeatService { if (this._heartbeatsCache === null) { return ''; } - const headerString = base64Encode(JSON.stringify(this._heartbeatsCache)); + const headerString = base64Encode( + JSON.stringify({ version: 2, heartbeats: this._heartbeatsCache }) + ); this._heartbeatsCache = null; // Do not wait for this, to reduce latency. - console.log('calling deleteAll'); void this._storage.deleteAll(); return headerString; } From 9852f5aebfc0f8e5309a856e5bfb000f68c3e866 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:13:45 -0800 Subject: [PATCH 09/11] Clean up, add storage_open error --- packages/app/src/indexeddb.ts | 13 +++++++++++-- packages/util/test/object.test.ts | 2 +- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/packages/app/src/indexeddb.ts b/packages/app/src/indexeddb.ts index 0bffa4ff50b..5bdaad0b1b3 100644 --- a/packages/app/src/indexeddb.ts +++ b/packages/app/src/indexeddb.ts @@ -36,6 +36,10 @@ function getDbPromise(): Promise { case 0: upgradeDB.createObjectStore(STORE_NAME); } + }).catch(e => { + throw ERROR_FACTORY.create(AppError.STORAGE_OPEN, { + originalErrorMessage: e.message + }); }); } return dbPromise; @@ -46,7 +50,10 @@ export async function readHeartbeatsFromIndexedDB( ): Promise { try { const db = await getDbPromise(); - return db.transaction(STORE_NAME).objectStore(STORE_NAME).get(computeKey(app)); + return db + .transaction(STORE_NAME) + .objectStore(STORE_NAME) + .get(computeKey(app)); } catch (e) { throw ERROR_FACTORY.create(AppError.STORAGE_GET, { originalErrorMessage: e.message @@ -71,7 +78,9 @@ export async function writeHeartbeatsToIndexedDB( } } -export async function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise { +export async function deleteHeartbeatsFromIndexedDB( + app: FirebaseApp +): Promise { try { const db = await getDbPromise(); const tx = db.transaction(STORE_NAME, 'readwrite'); diff --git a/packages/util/test/object.test.ts b/packages/util/test/object.test.ts index a3691aacc8e..d6f86209a83 100644 --- a/packages/util/test/object.test.ts +++ b/packages/util/test/object.test.ts @@ -19,7 +19,7 @@ import { expect } from 'chai'; import { deepEqual } from '../src/obj'; // eslint-disable-next-line no-restricted-properties -describe('deepEqual()', () => { +describe.only('deepEqual()', () => { it('returns true for comparing empty objects', () => { expect(deepEqual({}, {})).to.be.true; }); From c462daa35a0cf6d01a09e9866a7bc5358182c365 Mon Sep 17 00:00:00 2001 From: Christina Holland Date: Thu, 2 Dec 2021 11:14:38 -0800 Subject: [PATCH 10/11] clean up --- packages/util/test/indexeddb.test.ts | 43 ---------------------------- 1 file changed, 43 deletions(-) delete mode 100644 packages/util/test/indexeddb.test.ts diff --git a/packages/util/test/indexeddb.test.ts b/packages/util/test/indexeddb.test.ts deleted file mode 100644 index 334d46710cc..00000000000 --- a/packages/util/test/indexeddb.test.ts +++ /dev/null @@ -1,43 +0,0 @@ -/** - * @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 { isIndexedDBAvailable } from '../src/environment'; -import { - IndexedDbDatabaseService, - idbRead, - idbWrite, - idbDelete -} from '../src/indexeddb'; - -describe('IndexedDbDatabaseService', () => { - it('Can write, read, and delete', async () => { - if (!isIndexedDBAvailable()) { - // skip if in Node - return; - } - const idb = new IndexedDbDatabaseService('test-db', 'test-store', 1, e => { - console.error(e); - }); - await idbWrite(idb, 'a-key', { data: 'abcd' }); - const result = await idbRead(idb, 'a-key'); - expect((result as any).data).to.equal('abcd'); - await idbDelete(idb, 'a-key'); - const resultAfterDelete = await idbRead(idb, 'a-key'); - expect(resultAfterDelete).to.not.exist; - }); -}); From 2629b46fb4e49ea8a5e451b00d18a1abb5d010f3 Mon Sep 17 00:00:00 2001 From: Sam Olsen Date: Thu, 2 Dec 2021 13:42:34 -0800 Subject: [PATCH 11/11] Auth heartbeat impl --- .../auth/src/api/authentication/token.test.ts | 21 +++++++++++++++- packages/auth/src/api/authentication/token.ts | 19 +++++++++----- packages/auth/src/api/index.test.ts | 25 +++++++++++++++++++ packages/auth/src/api/index.ts | 15 ++++++++--- packages/auth/src/core/auth/auth_impl.test.ts | 8 ++++-- packages/auth/src/core/auth/auth_impl.ts | 10 +++++++- packages/auth/src/core/auth/register.ts | 7 +++--- packages/auth/src/model/auth.ts | 1 + .../auth/src/platform_browser/auth.test.ts | 10 +++++--- packages/auth/test/helpers/mock_auth.ts | 2 ++ 10 files changed, 99 insertions(+), 19 deletions(-) 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,