quodolores
Version:
Monorepo for the Firebase JavaScript SDK
448 lines (400 loc) • 13 kB
text/typescript
/**
* @license
* Copyright 2019 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 { Persistence } from '../../model/public_types';
import {
PersistedBlob,
PersistenceInternal as InternalPersistence,
PersistenceType,
PersistenceValue,
StorageEventListener,
STORAGE_AVAILABLE_KEY
} from '../../core/persistence/';
import {
_EventType,
_PingResponse,
KeyChangedResponse,
KeyChangedRequest,
PingRequest,
_TimeoutDuration
} from '../messagechannel/index';
import { Receiver } from '../messagechannel/receiver';
import { Sender } from '../messagechannel/sender';
import {
_isWorker,
_getActiveServiceWorker,
_getServiceWorkerController,
_getWorkerGlobalScope
} from '../util/worker';
export const DB_NAME = 'firebaseLocalStorageDb';
const DB_VERSION = 1;
const DB_OBJECTSTORE_NAME = 'firebaseLocalStorage';
const DB_DATA_KEYPATH = 'fbase_key';
interface DBObject {
[DB_DATA_KEYPATH]: string;
value: PersistedBlob;
}
/**
* Promise wrapper for IDBRequest
*
* Unfortunately we can't cleanly extend Promise<T> since promises are not callable in ES6
*
*/
class DBPromise<T> {
constructor(private readonly request: IDBRequest) {}
toPromise(): Promise<T> {
return new Promise<T>((resolve, reject) => {
this.request.addEventListener('success', () => {
resolve(this.request.result);
});
this.request.addEventListener('error', () => {
reject(this.request.error);
});
});
}
}
function getObjectStore(db: IDBDatabase, isReadWrite: boolean): IDBObjectStore {
return db
.transaction([DB_OBJECTSTORE_NAME], isReadWrite ? 'readwrite' : 'readonly')
.objectStore(DB_OBJECTSTORE_NAME);
}
export async function _clearDatabase(db: IDBDatabase): Promise<void> {
const objectStore = getObjectStore(db, true);
return new DBPromise<void>(objectStore.clear()).toPromise();
}
export function _deleteDatabase(): Promise<void> {
const request = indexedDB.deleteDatabase(DB_NAME);
return new DBPromise<void>(request).toPromise();
}
export function _openDatabase(): Promise<IDBDatabase> {
const request = indexedDB.open(DB_NAME, DB_VERSION);
return new Promise((resolve, reject) => {
request.addEventListener('error', () => {
reject(request.error);
});
request.addEventListener('upgradeneeded', () => {
const db = request.result;
try {
db.createObjectStore(DB_OBJECTSTORE_NAME, { keyPath: DB_DATA_KEYPATH });
} catch (e) {
reject(e);
}
});
request.addEventListener('success', async () => {
const db: IDBDatabase = request.result;
// Strange bug that occurs in Firefox when multiple tabs are opened at the
// same time. The only way to recover seems to be deleting the database
// and re-initializing it.
// https://github.com/firebase/firebase-js-sdk/issues/634
if (!db.objectStoreNames.contains(DB_OBJECTSTORE_NAME)) {
await _deleteDatabase();
return _openDatabase();
} else {
resolve(db);
}
});
});
}
export async function _putObject(
db: IDBDatabase,
key: string,
value: PersistenceValue | string
): Promise<void> {
const request = getObjectStore(db, true).put({
[DB_DATA_KEYPATH]: key,
value
});
return new DBPromise<void>(request).toPromise();
}
async function getObject(
db: IDBDatabase,
key: string
): Promise<PersistedBlob | null> {
const request = getObjectStore(db, false).get(key);
const data = await new DBPromise<DBObject | undefined>(request).toPromise();
return data === undefined ? null : data.value;
}
export function _deleteObject(db: IDBDatabase, key: string): Promise<void> {
const request = getObjectStore(db, true).delete(key);
return new DBPromise<void>(request).toPromise();
}
export const _POLLING_INTERVAL_MS = 800;
export const _TRANSACTION_RETRY_COUNT = 3;
class IndexedDBLocalPersistence implements InternalPersistence {
static type: 'LOCAL' = 'LOCAL';
type = PersistenceType.LOCAL;
db?: IDBDatabase;
private readonly listeners: Record<string, Set<StorageEventListener>> = {};
private readonly localCache: Record<string, PersistenceValue | null> = {};
// setTimeout return value is platform specific
// eslint-disable-next-line @typescript-eslint/no-explicit-any
private pollTimer: any | null = null;
private pendingWrites = 0;
private receiver: Receiver | null = null;
private sender: Sender | null = null;
private serviceWorkerReceiverAvailable = false;
private activeServiceWorker: ServiceWorker | null = null;
// Visible for testing only
readonly _workerInitializationPromise: Promise<void>;
constructor() {
// Fire & forget the service worker registration as it may never resolve
this._workerInitializationPromise = this.initializeServiceWorkerMessaging().then(
() => {},
() => {}
);
}
async _openDb(): Promise<IDBDatabase> {
if (this.db) {
return this.db;
}
this.db = await _openDatabase();
return this.db;
}
async _withRetries<T>(op: (db: IDBDatabase) => Promise<T>): Promise<T> {
let numAttempts = 0;
while (true) {
try {
const db = await this._openDb();
return await op(db);
} catch (e) {
if (numAttempts++ > _TRANSACTION_RETRY_COUNT) {
throw e;
}
if (this.db) {
this.db.close();
this.db = undefined;
}
// TODO: consider adding exponential backoff
}
}
}
/**
* IndexedDB events do not propagate from the main window to the worker context. We rely on a
* postMessage interface to send these events to the worker ourselves.
*/
private async initializeServiceWorkerMessaging(): Promise<void> {
return _isWorker() ? this.initializeReceiver() : this.initializeSender();
}
/**
* As the worker we should listen to events from the main window.
*/
private async initializeReceiver(): Promise<void> {
this.receiver = Receiver._getInstance(_getWorkerGlobalScope()!);
// Refresh from persistence if we receive a KeyChanged message.
this.receiver._subscribe(
_EventType.KEY_CHANGED,
async (_origin: string, data: KeyChangedRequest) => {
const keys = await this._poll();
return {
keyProcessed: keys.includes(data.key)
};
}
);
// Let the sender know that we are listening so they give us more timeout.
this.receiver._subscribe(
_EventType.PING,
async (_origin: string, _data: PingRequest) => {
return [_EventType.KEY_CHANGED];
}
);
}
/**
* As the main window, we should let the worker know when keys change (set and remove).
*
* @remarks
* {@link https://developer.mozilla.org/en-US/docs/Web/API/ServiceWorkerContainer/ready | ServiceWorkerContainer.ready}
* may not resolve.
*/
private async initializeSender(): Promise<void> {
// Check to see if there's an active service worker.
this.activeServiceWorker = await _getActiveServiceWorker();
if (!this.activeServiceWorker) {
return;
}
this.sender = new Sender(this.activeServiceWorker);
// Ping the service worker to check what events they can handle.
const results = await this.sender._send<_PingResponse, PingRequest>(
_EventType.PING,
{},
_TimeoutDuration.LONG_ACK
);
if (!results) {
return;
}
if (
results[0]?.fulfilled &&
results[0]?.value.includes(_EventType.KEY_CHANGED)
) {
this.serviceWorkerReceiverAvailable = true;
}
}
/**
* Let the worker know about a changed key, the exact key doesn't technically matter since the
* worker will just trigger a full sync anyway.
*
* @remarks
* For now, we only support one service worker per page.
*
* @param key - Storage key which changed.
*/
private async notifyServiceWorker(key: string): Promise<void> {
if (
!this.sender ||
!this.activeServiceWorker ||
_getServiceWorkerController() !== this.activeServiceWorker
) {
return;
}
try {
await this.sender._send<KeyChangedResponse, KeyChangedRequest>(
_EventType.KEY_CHANGED,
{ key },
// Use long timeout if receiver has previously responded to a ping from us.
this.serviceWorkerReceiverAvailable
? _TimeoutDuration.LONG_ACK
: _TimeoutDuration.ACK
);
} catch {
// This is a best effort approach. Ignore errors.
}
}
async _isAvailable(): Promise<boolean> {
try {
if (!indexedDB) {
return false;
}
const db = await _openDatabase();
await _putObject(db, STORAGE_AVAILABLE_KEY, '1');
await _deleteObject(db, STORAGE_AVAILABLE_KEY);
return true;
} catch {}
return false;
}
private async _withPendingWrite(write: () => Promise<void>): Promise<void> {
this.pendingWrites++;
try {
await write();
} finally {
this.pendingWrites--;
}
}
async _set(key: string, value: PersistenceValue): Promise<void> {
return this._withPendingWrite(async () => {
await this._withRetries((db: IDBDatabase) => _putObject(db, key, value));
this.localCache[key] = value;
return this.notifyServiceWorker(key);
});
}
async _get<T extends PersistenceValue>(key: string): Promise<T | null> {
const obj = (await this._withRetries((db: IDBDatabase) =>
getObject(db, key)
)) as T;
this.localCache[key] = obj;
return obj;
}
async _remove(key: string): Promise<void> {
return this._withPendingWrite(async () => {
await this._withRetries((db: IDBDatabase) => _deleteObject(db, key));
delete this.localCache[key];
return this.notifyServiceWorker(key);
});
}
private async _poll(): Promise<string[]> {
// TODO: check if we need to fallback if getAll is not supported
const result = await this._withRetries((db: IDBDatabase) => {
const getAllRequest = getObjectStore(db, false).getAll();
return new DBPromise<DBObject[] | null>(getAllRequest).toPromise();
});
if (!result) {
return [];
}
// If we have pending writes in progress abort, we'll get picked up on the next poll
if (this.pendingWrites !== 0) {
return [];
}
const keys = [];
const keysInResult = new Set();
for (const { fbase_key: key, value } of result) {
keysInResult.add(key);
if (JSON.stringify(this.localCache[key]) !== JSON.stringify(value)) {
this.notifyListeners(key, value as PersistenceValue);
keys.push(key);
}
}
for (const localKey of Object.keys(this.localCache)) {
if (this.localCache[localKey] && !keysInResult.has(localKey)) {
// Deleted
this.notifyListeners(localKey, null);
keys.push(localKey);
}
}
return keys;
}
private notifyListeners(
key: string,
newValue: PersistenceValue | null
): void {
this.localCache[key] = newValue;
const listeners = this.listeners[key];
if (listeners) {
for (const listener of Array.from(listeners)) {
listener(newValue);
}
}
}
private startPolling(): void {
this.stopPolling();
this.pollTimer = setInterval(
async () => this._poll(),
_POLLING_INTERVAL_MS
);
}
private stopPolling(): void {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
_addListener(key: string, listener: StorageEventListener): void {
if (Object.keys(this.listeners).length === 0) {
this.startPolling();
}
if (!this.listeners[key]) {
this.listeners[key] = new Set();
// Populate the cache to avoid spuriously triggering on first poll.
void this._get(key); // This can happen in the background async and we can return immediately.
}
this.listeners[key].add(listener);
}
_removeListener(key: string, listener: StorageEventListener): void {
if (this.listeners[key]) {
this.listeners[key].delete(listener);
if (this.listeners[key].size === 0) {
delete this.listeners[key];
}
}
if (Object.keys(this.listeners).length === 0) {
this.stopPolling();
}
}
}
/**
* An implementation of {@link Persistence} of type 'LOCAL' using `indexedDB`
* for the underlying storage.
*
* @public
*/
export const indexedDBLocalPersistence: Persistence = IndexedDBLocalPersistence;