Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement heartbeat controller #5723

Merged
merged 16 commits into from
Feb 24, 2022
20 changes: 18 additions & 2 deletions packages/app/src/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppError> = {
Expand All @@ -38,7 +42,15 @@ const ERRORS: ErrorMap<AppError> = {
'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 {
Expand All @@ -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<AppError, ErrorParams>(
Expand Down
161 changes: 161 additions & 0 deletions packages/app/src/heartbeatService.ts
Original file line number Diff line number Diff line change
@@ -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 {
deleteHeartbeatsFromIndexedDB,
readHeartbeatsFromIndexedDB,
writeHeartbeatsToIndexedDB
} from './indexeddb';
import { FirebaseApp } from './public-types';
import {
HeartbeatsByUserAgent,
HeartbeatService,
HeartbeatStorage
} from './types';

export class HeartbeatServiceImpl implements HeartbeatService {
storage: HeartbeatStorageImpl;
heartbeatsCache: HeartbeatsByUserAgent[] | null = null;
heartbeatsCachePromise: Promise<HeartbeatsByUserAgent[]>;
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<void> {
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([]);
hsubox76 marked this conversation as resolved.
Show resolved Hide resolved
}
async getHeartbeatsHeader(): Promise<string> {
if (!this.heartbeatsCache) {
await this.heartbeatsCachePromise;
}
return base64Encode(JSON.stringify(this.heartbeatsCache!));
}
}

function getDateString(): string {
const today = new Date();
hsubox76 marked this conversation as resolved.
Show resolved Hide resolved
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}`;
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you want to get rid of the custom logic here you could just do:

today.toISOString().substring(0,10)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks! Changed.

}

export class HeartbeatStorageImpl implements HeartbeatStorage {
private _canUseIndexedDBPromise: Promise<boolean>;
constructor(public app: FirebaseApp) {
this._canUseIndexedDBPromise = this.runIndexedDBEnvironmentCheck();
}
async runIndexedDBEnvironmentCheck(): Promise<boolean> {
if (!isIndexedDBAvailable()) {
return false;
} else {
return validateIndexedDBOpenable()
.then(() => true)
.catch(() => false);
}
}
/**
* Read all heartbeats.
*/
async read(): Promise<HeartbeatsByUserAgent[]> {
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<void> {
const canUseIndexedDB = await this._canUseIndexedDBPromise;
if (!canUseIndexedDB) {
return;
} else {
return writeHeartbeatsToIndexedDB(this.app, { heartbeats });
}
}
// add heartbeats
async add(heartbeats: HeartbeatsByUserAgent[]): Promise<void> {
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<void> {
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<void> {
const canUseIndexedDB = await this._canUseIndexedDBPromise;
if (!canUseIndexedDB) {
return;
} else {
return deleteHeartbeatsFromIndexedDB(this.app);
}
}
}
168 changes: 168 additions & 0 deletions packages/app/src/indexeddb.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
/**
* @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<IDBDatabase> | null = null;
function getDBPromise(): Promise<IDBDatabase> {
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<HeartbeatsInIndexedDB | undefined> {
return read(computeKey(app)) as Promise<HeartbeatsInIndexedDB | undefined>;
}

export function writeHeartbeatsToIndexedDB(
app: FirebaseApp,
heartbeatObject: HeartbeatsInIndexedDB
): Promise<void> {
return write(computeKey(app), heartbeatObject);
}

export function deleteHeartbeatsFromIndexedDB(app: FirebaseApp): Promise<void> {
return deleteEntry(computeKey(app));
}

async function write(key: string, value: unknown): Promise<void> {
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<unknown> {
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<void> {
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}`;
}
4 changes: 4 additions & 0 deletions packages/app/src/internal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,10 @@ export function _getProvider<T extends Name>(
app: FirebaseApp,
name: T
): Provider<T> {
const heartbeatController = (app as FirebaseAppImpl).container
.getProvider('heartbeat')
.getImmediate();
void heartbeatController.triggerHeartbeat();
return (app as FirebaseAppImpl).container.getProvider(name);
}

Expand Down
Loading