UNPKG

quodolores

Version:

Monorepo for the Firebase JavaScript SDK

448 lines (400 loc) 13 kB
/** * @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;