UNPKG

firetruss

Version:

Advanced data sync layer for Firebase and Vue.js

378 lines (334 loc) 11.5 kB
import {unescapeKey} from './utils/paths.js'; import _ from 'lodash'; const MIN_WORKER_VERSION = '2.0.0'; class Snapshot { constructor({path, value, exists, writeSerial}) { this._path = path; this._value = value; this._exists = value === undefined ? exists || false : value !== null; this._writeSerial = writeSerial; } get path() { return this._path; } get exists() { return this._exists; } get value() { if (this._value === undefined) throw new Error('Value omitted from snapshot'); return this._value; } get key() { if (this._key === undefined) this._key = unescapeKey(this._path.replace(/.*\//, '')); return this._key; } get writeSerial() { return this._writeSerial; } } export default class Bridge { constructor(webWorker) { this._idCounter = 0; this._deferreds = {}; this._suspended = false; this._servers = {}; this._callbacks = {}; this._log = _.noop; this._inboundMessages = []; this._outboundMessages = []; this._flushMessageQueue = this._flushMessageQueue.bind(this); this._port = webWorker.port || webWorker; this._shared = !!webWorker.port; Object.seal(this); this._port.onmessage = this._receive.bind(this); window.addEventListener('unload', () => {this._send({msg: 'destroy'});}); } init(webWorker, config) { const items = []; try { const storage = window.localStorage || window.sessionStorage; if (!storage) throw new Error('localStorage and sessionStorage not available'); for (let i = 0; i < storage.length; i++) { const key = storage.key(i); items.push({key, value: storage.getItem(key)}); } } catch (e) { // Some browsers don't like us accessing local storage -- nothing we can do. } return this._send({msg: 'init', storage: items, config}).then(response => { const workerVersion = response.version.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/); if (workerVersion) { const minVersion = MIN_WORKER_VERSION.match(/^(\d+)\.(\d+)\.(\d+)(-.*)?$/); // Major version must match precisely, minor and patch must be greater than or equal. const sufficient = workerVersion[1] === minVersion[1] && ( workerVersion[2] > minVersion[2] || workerVersion[2] === minVersion[2] && workerVersion[3] >= minVersion[3] ); if (!sufficient) { return Promise.reject(new Error( `Incompatible Firetruss worker version: ${response.version} ` + `(${MIN_WORKER_VERSION} or better required)` )); } } return response; }); } suspend(suspended) { if (suspended === undefined) suspended = true; if (this._suspended === suspended) return; this._suspended = suspended; if (!suspended) { this._receiveMessages(this._inboundMessages); this._inboundMessages = []; if (this._outboundMessages.length) Promise.resolve().then(this._flushMessageQueue); } } enableLogging(fn) { if (fn) { if (fn === true) fn = console.log.bind(console); this._log = fn; } else { this._log = _.noop; } } _send(message) { message.id = ++this._idCounter; let promise; if (message.oneWay) { promise = Promise.resolve(); } else { promise = new Promise((resolve, reject) => { this._deferreds[message.id] = {resolve, reject}; }); const deferred = this._deferreds[message.id]; deferred.promise = promise; promise.sent = new Promise(resolve => { deferred.resolveSent = resolve; }); deferred.params = message; } if (!this._outboundMessages.length && !this._suspended) { Promise.resolve().then(this._flushMessageQueue); } this._log('send:', message); this._outboundMessages.push(message); return promise; } _flushMessageQueue() { try { this._port.postMessage(this._outboundMessages); this._outboundMessages = []; } catch (e) { e.extra = {messages: this._outboundMessages}; throw e; } } _receive(event) { if (this._suspended) { this._inboundMessages = this._inboundMessages.concat(event.data); } else { this._receiveMessages(event.data); } } _receiveMessages(messages) { for (const message of messages) { this._log('recv:', message); const fn = this[message.msg]; if (!_.isFunction(fn)) throw new Error('Unknown message: ' + message.msg); fn.call(this, message); } } bindExposedFunction(name) { return (function() { return this._send({msg: 'call', name, args: Array.prototype.slice.call(arguments)}); }).bind(this); } resolve(message) { const deferred = this._deferreds[message.id]; if (!deferred) throw new Error('Received resolution to inexistent Firebase call'); delete this._deferreds[message.id]; deferred.resolve(message.result); } reject(message) { const deferred = this._deferreds[message.id]; if (!deferred) throw new Error('Received rejection of inexistent Firebase call'); delete this._deferreds[message.id]; deferred.reject(errorFromJson(message.error, deferred.params)); } updateLocalStorage({items}) { try { const storage = window.localStorage || window.sessionStorage; for (const item of items) { if (item.value === null) { storage.removeItem(item.key); } else { storage.setItem(item.key, item.value); } } } catch (e) { // If we're denied access, there's nothing we can do. } } trackServer(rootUrl) { if (this._servers.hasOwnProperty(rootUrl)) return Promise.resolve(); const server = this._servers[rootUrl] = {authListeners: []}; const authCallbackId = this._registerCallback(this._authCallback.bind(this, server)); this._send({msg: 'onAuth', url: rootUrl, callbackId: authCallbackId}); } _authCallback(server, auth) { server.auth = auth; for (const listener of server.authListeners) listener(auth); } onAuth(rootUrl, callback, context) { const listener = callback.bind(context); listener.callback = callback; listener.context = context; this._servers[rootUrl].authListeners.push(listener); listener(this.getAuth(rootUrl)); } offAuth(rootUrl, callback, context) { const authListeners = this._servers[rootUrl].authListeners; for (let i = 0; i < authListeners.length; i++) { const listener = authListeners[i]; if (listener.callback === callback && listener.context === context) { authListeners.splice(i, 1); break; } } } getAuth(rootUrl) { return this._servers[rootUrl].auth; } authWithCustomToken(url, authToken) { return this._send({msg: 'authWithCustomToken', url, authToken}); } unauth(url) { return this._send({msg: 'unauth', url}); } set(url, value, writeSerial) {return this._send({msg: 'set', url, value, writeSerial});} update(url, value, writeSerial) {return this._send({msg: 'update', url, value, writeSerial});} once(url, writeSerial) { return this._send({msg: 'once', url, writeSerial}).then(snapshot => new Snapshot(snapshot)); } on(listenerKey, url, spec, eventType, snapshotCallback, cancelCallback, context, options) { const handle = { listenerKey, eventType, snapshotCallback, cancelCallback, context, params: {msg: 'on', listenerKey, url, spec, eventType, options} }; const callback = this._onCallback.bind(this, handle); this._registerCallback(callback, handle); // Keep multiple IDs to allow the same snapshotCallback to be reused. snapshotCallback.__callbackIds = snapshotCallback.__callbackIds || []; snapshotCallback.__callbackIds.push(handle.id); this._send({ msg: 'on', listenerKey, url, spec, eventType, callbackId: handle.id, options }).catch(error => { callback(error); }); } off(listenerKey, url, spec, eventType, snapshotCallback, context) { const idsToDeregister = []; let callbackId; if (snapshotCallback) { callbackId = this._findAndRemoveCallbackId( snapshotCallback, handle => _.isMatch(handle, {listenerKey, eventType, context}) ); if (!callbackId) return Promise.resolve(); // no-op, never registered or already deregistered idsToDeregister.push(callbackId); } else { for (const id of _.keys(this._callbacks)) { const handle = this._callbacks[id]; if (handle.listenerKey === listenerKey && (!eventType || handle.eventType === eventType)) { idsToDeregister.push(id); } } } // Nullify callbacks first, then deregister after off() is complete. We don't want any // callbacks in flight from the worker to be invoked while the off() is processing, but we don't // want them to throw an exception either. for (const id of idsToDeregister) this._nullifyCallback(id); return this._send({msg: 'off', listenerKey, url, spec, eventType, callbackId}).then(() => { for (const id of idsToDeregister) this._deregisterCallback(id); }); } _onCallback(handle, error, snapshotJson) { if (error) { this._deregisterCallback(handle.id); const e = errorFromJson(error, handle.params); if (handle.cancelCallback) { handle.cancelCallback.call(handle.context, e); } else { console.error(e); } } else { handle.snapshotCallback.call(handle.context, new Snapshot(snapshotJson)); } } transaction(url, oldValue, relativeUpdates, writeSerial) { return this._send( {msg: 'transaction', url, oldValue, relativeUpdates, writeSerial} ).then(result => { if (result.snapshots) { result.snapshots = _.map(result.snapshots, jsonSnapshot => new Snapshot(jsonSnapshot)); } return result; }); } onDisconnect(url, method, value) { return this._send({msg: 'onDisconnect', url, method, value}); } bounceConnection() { return this._send({msg: 'bounceConnection'}); } callback({id, args}) { const handle = this._callbacks[id]; if (!handle) throw new Error('Unregistered callback: ' + id); handle.callback.apply(null, args); } _registerCallback(callback, handle) { handle = handle || {}; handle.callback = callback; handle.id = `cb${++this._idCounter}`; this._callbacks[handle.id] = handle; return handle.id; } _nullifyCallback(id) { this._callbacks[id].callback = _.noop; } _deregisterCallback(id) { delete this._callbacks[id]; } _findAndRemoveCallbackId(callback, predicate) { if (!callback.__callbackIds) return; let i = 0; while (i < callback.__callbackIds.length) { const id = callback.__callbackIds[i]; const handle = this._callbacks[id]; if (!handle) { callback.__callbackIds.splice(i, 1); continue; } if (predicate(handle)) { callback.__callbackIds.splice(i, 1); return id; } i += 1; } } } function errorFromJson(json, params) { if (!json || _.isError(json)) return json; const error = new Error(json.message); error.params = params; for (const propertyName in json) { if (propertyName === 'message' || !json.hasOwnProperty(propertyName)) continue; try { error[propertyName] = json[propertyName]; } catch (e) { error.extra = error.extra || {}; error.extra[propertyName] = json[propertyName]; } } return error; }