UNPKG

firetruss

Version:

Advanced data sync layer for Firebase and Vue.js

1,480 lines (1,298 loc) 142 kB
(function (global, factory) { typeof exports === 'object' && typeof module !== 'undefined' ? module.exports = factory(require('lodash'), require('vue')) : typeof define === 'function' && define.amd ? define(['lodash', 'vue'], factory) : (global = typeof globalThis !== 'undefined' ? globalThis : global || self, global.Truss = factory(global._, global.Vue)); })(this, (function (_, Vue) { 'use strict'; function _interopDefaultLegacy (e) { return e && typeof e === 'object' && 'default' in e ? e : { default: e }; } var ___default = /*#__PURE__*/_interopDefaultLegacy(_); var Vue__default = /*#__PURE__*/_interopDefaultLegacy(Vue); let vue; let lastDigestRequest = 0, digestInProgress = false; const bareDigest = function() { if (vue.digestRequest > lastDigestRequest) return; vue.digestRequest = lastDigestRequest + 1; }; const angularProxy = { active: typeof window !== 'undefined' && window.angular }; if (angularProxy.active) { initAngular(); } else { ___default.default.forEach(['digest', 'watch', 'defineModule', 'debounceDigest'], method => { angularProxy[method] = ___default.default.noop; }); } function initAngular() { const module = window.angular.module('firetruss', []); angularProxy.digest = bareDigest; angularProxy.watch = function() {throw new Error('Angular watch proxy not yet initialized');}; angularProxy.defineModule = function(Truss) { module.constant('Truss', Truss); }; angularProxy.debounceDigest = function(wait) { if (wait) { const debouncedDigest = ___default.default.debounce(bareDigest, wait); angularProxy.digest = function() { if (vue.digestRequest > lastDigestRequest) return; if (digestInProgress) bareDigest(); else debouncedDigest(); }; } else { angularProxy.digest = bareDigest; } }; module.config(['$provide', function($provide) { $provide.decorator('$rootScope', ['$delegate', '$exceptionHandler', function($delegate, $exceptionHandler) { const rootScope = $delegate; angularProxy.watch = rootScope.$watch.bind(rootScope); const proto = Object.getPrototypeOf(rootScope); const angularDigest = proto.$digest; proto.$digest = bareDigest; proto.$digest.original = angularDigest; vue = new Vue__default.default({data: {digestRequest: 0}}); vue.$watch(() => vue.digestRequest, () => { if (vue.digestRequest > lastDigestRequest) { // Make sure we execute the digest outside the Vue task queue, because otherwise if the // client replaced Promise with angular.$q all Truss.nextTick().then() functions will be // executed inside the Angular digest and hence inside the Vue task queue. But // Truss.nextTick() is used precisely to avoid that. Note that it's OK to use // Vue.nextTick() here because even though it will schedule a flush via Promise.then() // it only uses the native Promise, before it could've been monkey-patched by the app. Vue__default.default.nextTick(() => { if (vue.digestRequest <= lastDigestRequest) return; digestInProgress = true; rootScope.$digest.original.call(rootScope); lastDigestRequest = vue.digestRequest = vue.digestRequest + 1; }); } else { digestInProgress = false; } }); const watcher = ___default.default.last(vue._watchers || vue._scope.effects); watcher.id = Infinity; // make sure watcher is scheduled last patchRenderWatcherGet(Object.getPrototypeOf(watcher)); return rootScope; } ]); }]); } // This is a kludge that catches errors that get through render watchers and end up killing the // entire Vue event loop (e.g., errors raised in transition callbacks). The state of the DOM may // not be consistent after such an error is caught, but the global error handler should stop the // world anyway. May be related to https://github.com/vuejs/vue/issues/7653. function patchRenderWatcherGet(prototype) { const originalGet = prototype.get; prototype.get = function get() { try { return originalGet.call(this); } catch (e) { if (this.vm._watcher === this && Vue__default.default.config.errorHandler) { Vue__default.default.config.errorHandler(e, this.vm, 'uncaught render error'); } else { throw e; } } }; } class LruCacheItem { constructor(key, value) { this.key = key; this.value = value; this.touch(); } touch() { this.timestamp = Date.now(); } } class LruCache { constructor(maxSize, pruningSize) { this._items = Object.create(null); this._size = 0; this._maxSize = maxSize; this._pruningSize = pruningSize || Math.ceil(maxSize * 0.10); } has(key) { return Boolean(this._items[key]); } get(key) { const item = this._items[key]; if (!item) return; item.touch(); return item.value; } set(key, value) { const item = this._items[key]; if (item) { item.value = value; } else { if (this._size >= this._maxSize) this._prune(); this._items[key] = new LruCacheItem(key, value); this._size += 1; } } delete(key) { const item = this._items[key]; if (!item) return; delete this._items[key]; this._size -= 1; } _prune() { const itemsToPrune = ___default.default(this._items).toArray().sortBy('timestamp').take(this._pruningSize).value(); for (const item of itemsToPrune) this.delete(item.key); } } const pathSegments = new LruCache(1000); const pathMatchers = {}; const maxNumPathMatchers = 1000; function escapeKey(key) { if (!key) return key; // eslint-disable-next-line no-control-regex return key.toString().replace(/[\x00-\x1f\\.$#[\]\x7f/]/g, char => '\\' + ___default.default.padStart(char.charCodeAt(0).toString(16), 2, '0') ); } function unescapeKey(key) { if (!key) return key; return key.toString().replace(/\\[0-9a-f]{2}/gi, code => String.fromCharCode(parseInt(code.slice(1), 16)) ); } function escapeKeys(object) { // isExtensible check avoids trying to escape references to Firetruss internals. if (!(___default.default.isObject(object) && Object.isExtensible(object))) return object; let result = object; for (const key in object) { if (!Object.hasOwn(object, key)) continue; const value = object[key]; const escapedKey = escapeKey(key); const escapedValue = escapeKeys(value); if (escapedKey !== key || escapedValue !== value) { if (result === object) result = ___default.default.clone(object); result[escapedKey] = escapedValue; if (result[key] === value) delete result[key]; } } return result; } function joinPath() { const segments = []; for (let segment of arguments) { if (!___default.default.isString(segment)) segment = '' + segment; if (segment.charAt(0) === '/') segments.splice(0, segments.length); segments.push(segment); } if (segments[0] === '/') segments[0] = ''; return segments.join('/'); } function splitPath(path, leaveSegmentsEscaped) { const key = (leaveSegmentsEscaped ? 'esc:' : '') + path; let segments = pathSegments.get(key); if (!segments) { segments = path.split('/'); if (!leaveSegmentsEscaped) segments = ___default.default.map(segments, unescapeKey); pathSegments.set(key, segments); } return segments; } class PathMatcher { constructor(pattern) { this.variables = []; const prefixMatch = ___default.default.endsWith(pattern, '/$*'); if (prefixMatch) pattern = pattern.slice(0, -3); const pathTemplate = pattern.replace(/\/\$[^/]*/g, match => { if (match.length > 1) this.variables.push(match.slice(1)); return '\u0001'; }); Object.freeze(this.variables); if (/[.$#[\]]|\\(?![0-9a-f][0-9a-f])/i.test(pathTemplate)) { throw new Error('Path pattern has unescaped keys: ' + pattern); } this._regex = new RegExp( // eslint-disable-next-line no-control-regex '^' + pathTemplate.replace(/\u0001/g, '/([^/]+)') + (prefixMatch ? '($|/)' : '$')); } match(path) { this._regex.lastIndex = 0; const match = this._regex.exec(path); if (!match) return; const bindings = {}; for (let i = 0; i < this.variables.length; i++) { bindings[this.variables[i]] = unescapeKey(match[i + 1]); } return bindings; } test(path) { return this._regex.test(path); } toString() { return this._regex.toString(); } } function makePathMatcher(pattern) { let matcher = pathMatchers[pattern]; if (!matcher) { matcher = new PathMatcher(pattern); // Minimal pseudo-LRU behavior, since we don't expect to actually fill up the cache. if (___default.default.size(pathMatchers) === maxNumPathMatchers) delete pathMatchers[___default.default.keys(pathMatchers)[0]]; pathMatchers[pattern] = matcher; } return matcher; } const MIN_WORKER_VERSION = '4.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; } } class Bridge { constructor(webWorker) { this._idCounter = 0; this._deferreds = {}; this._suspended = false; this._servers = {}; this._callbacks = {}; this._log = ___default.default.noop; this._inboundMessages = []; this._outboundMessages = []; this._flushMessageQueue = this._flushMessageQueue.bind(this); this._port = webWorker.port || webWorker; this._shared = !!webWorker.port; this._dead = undefined; Object.seal(this); this._port.onmessage = this._receive.bind(this); } init(lockName, 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 { // Some browsers don't like us accessing local storage -- nothing we can do. } return this._send({msg: 'init', storage: items, config, lockName}).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)` )); } } if (response.livenessLockName) { navigator.locks.request(response.livenessLockName, () => { this.crash({error: { name: 'Error', message: 'worker terminated', extra: {shared: this._shared} }}); }); } 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._send({msg: 'enableFirebaseLogging', value: true}); } this._log = fn; } else { this._send({msg: 'enableFirebaseLogging', value: false}); this._log = ___default.default.noop; } } _send(message) { message.id = ++this._idCounter; let promise; if (this._dead) { return Promise.reject(this._dead); } else 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; 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() { this._log('flush:', this._outboundMessages.length, 'messages'); try { this._port.postMessage(this._outboundMessages); this._outboundMessages = []; } catch (e) { this._log('flush failed:', e); e.extra = {messages: this._outboundMessages}; throw e; } } _receive(event) { if (this._dead) return; 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 (!___default.default.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)); } crash(message) { let details = `Internal worker error: ${message.error.name}: ${message.error.message}`; if (message.error.cause) details += ` (caused by ${message.error.cause})`; this._dead = new Error(details); if (message.error.extra) this._dead.extra = message.error.extra; ___default.default.forEach(this._deferreds, ({reject}) => {reject(this._dead);}); this._deferreds = {}; throw this._dead; } 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 { // If we're denied access, there's nothing we can do. } } trackServer(rootUrl) { if (Object.hasOwn(this._servers, 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}); } authAnonymously(url) { return this._send({msg: 'authAnonymously', url}); } 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 => ___default.default.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 ___default.default.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 = ___default.default.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 = ___default.default.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 || ___default.default.isError(json)) return json; const error = new Error(json.message); try { error.params = params; for (const propertyName in json) { if (propertyName === 'message' || !Object.hasOwn(json, propertyName)) continue; try { error[propertyName] = json[propertyName]; } catch { error.extra = error.extra || {}; error.extra[propertyName] = json[propertyName]; } } } catch (e) { if (!/object is not extensible/.test(e.message)) throw e; } return error; } /* eslint-disable no-use-before-define */ const EMPTY_ANNOTATIONS = {}; Object.freeze(EMPTY_ANNOTATIONS); class Handle { constructor(tree, path, annotations) { this._tree = tree; this._path = path.replace(/^\/*/, '/').replace(/\/$/, '') || '/'; if (annotations) { this._annotations = annotations; Object.freeze(annotations); } } get $ref() {return this;} get key() { if (!this._key) this._key = unescapeKey(this._path.replace(/.*\//, '')); return this._key; } get path() {return this._path;} get _pathPrefix() {return this._path === '/' ? '' : this._path;} get parent() { return new Reference(this._tree, this._path.replace(/\/[^/]*$/, ''), this._annotations); } get annotations() { return this._annotations || EMPTY_ANNOTATIONS; } child() { if (!arguments.length) return this; const segments = []; for (const key of arguments) { if (___default.default.isNil(key)) return; segments.push(escapeKey(key)); } return new Reference( this._tree, `${this._pathPrefix}/${segments.join('/')}`, this._annotations ); } children() { if (!arguments.length) return this; const escapedKeys = []; for (let i = 0; i < arguments.length; i++) { const arg = arguments[i]; if (___default.default.isArray(arg)) { const mapping = {}; const subPath = this._pathPrefix + (escapedKeys.length ? `/${escapedKeys.join('/')}` : ''); const rest = ___default.default.slice(arguments, i + 1); for (const key of arg) { const subRef = new Reference(this._tree, `${subPath}/${escapeKey(key)}`, this._annotations); const subMapping = subRef.children.apply(subRef, rest); if (subMapping) mapping[key] = subMapping; } return mapping; } if (___default.default.isNil(arg)) return; escapedKeys.push(escapeKey(arg)); } return new Reference( this._tree, `${this._pathPrefix}/${escapedKeys.join('/')}`, this._annotations); } peek(callback) { return this._tree.truss.peek(this, callback); } match(pattern) { return makePathMatcher(pattern).match(this.path); } test(pattern) { return makePathMatcher(pattern).test(this.path); } isEqual(that) { if (!(that instanceof Handle)) return false; return this._tree === that._tree && this.toString() === that.toString() && ___default.default.isEqual(this._annotations, that._annotations); } belongsTo(truss) { return this._tree.truss === truss; } } class Query extends Handle { constructor(tree, path, spec, annotations) { super(tree, path, annotations); this._spec = this._copyAndValidateSpec(spec); const queryTerms = ___default.default(this._spec) .map((value, key) => `${key}=${encodeURIComponent(JSON.stringify(value))}`) .sortBy() .join('&'); this._string = `${this._path}?${queryTerms}`; Object.freeze(this); } // Vue-bound get ready() { return this._tree.isQueryReady(this); } get constraints() { return this._spec; } annotate(annotations) { return new Query( this._tree, this._path, this._spec, ___default.default.assign({}, this._annotations, annotations)); } _copyAndValidateSpec(spec) { if (!spec.by) throw new Error('Query needs "by" clause: ' + JSON.stringify(spec)); if (('at' in spec) + ('from' in spec) + ('to' in spec) > 1) { throw new Error( 'Query must contain at most one of "at", "from", or "to" clauses: ' + JSON.stringify(spec)); } if (('first' in spec) + ('last' in spec) > 1) { throw new Error( 'Query must contain at most one of "first" or "last" clauses: ' + JSON.stringify(spec)); } if (!___default.default.some(['at', 'from', 'to', 'first', 'last'], clause => clause in spec)) { throw new Error( 'Query must contain at least one of "at", "from", "to", "first", or "last" clauses: ' + JSON.stringify(spec)); } spec = ___default.default.clone(spec); if (spec.by !== '$key' && spec.by !== '$value') { if (!(spec.by instanceof Reference)) { throw new Error('Query "by" value must be a reference: ' + spec.by); } let childPath = spec.by.toString(); if (!___default.default.startsWith(childPath, this._path)) { throw new Error( 'Query "by" value must be a descendant of target reference: ' + spec.by); } childPath = childPath.slice(this._path.length).replace(/^\/?/, ''); if (!___default.default.includes(childPath, '/')) { throw new Error( 'Query "by" value must not be a direct child of target reference: ' + spec.by); } spec.by = childPath.replace(/.*?\//, ''); } Object.freeze(spec); return spec; } toString() {return this._string;} toJSON() {return `query → ${this.toString()}`;} } class Reference extends Handle { constructor(tree, path, annotations) { super(tree, path, annotations); Object.freeze(this); } get ready() {return this._tree.isReferenceReady(this);} // Vue-bound get value() {return this._tree.getObject(this.path);} // Vue-bound toString() {return this._path;} toJSON() {return `reference → ${this.toString()}`;} annotate(annotations) { return new Reference(this._tree, this._path, ___default.default.assign({}, this._annotations, annotations)); } query(spec) { return new Query(this._tree, this._path, spec, this._annotations); } set(value) { this._checkForUndefinedPath(); return this._tree.update(this, 'set', {[this.path]: value}); } update(values) { this._checkForUndefinedPath(); return this._tree.update(this, 'update', values); } override(value) { this._checkForUndefinedPath(); return this._tree.update(this, 'override', {[this.path]: value}); } commit(updateFunction) { this._checkForUndefinedPath(); return this._tree.commit(this, updateFunction); } _checkForUndefinedPath() { if (this.path === '/undefined') throw new Error('Invalid path for operation: ' + this.path); } } const SERVER_TIMESTAMP = Object.freeze({'.sv': 'timestamp'}); function isTrussEqual(a, b) { return ___default.default.isEqualWith(a, b, isTrussValueEqual); } function isTrussValueEqual(a, b) { if (a === b || a === undefined || a === null || b === undefined || b === null || a.$truss || b.$truss) return a === b; if (a.isEqual) return a.isEqual(b); } function copyPrototype(a, b) { for (const prop of Object.getOwnPropertyNames(a.prototype)) { if (prop === 'constructor') continue; Object.defineProperty(b.prototype, prop, Object.getOwnPropertyDescriptor(a.prototype, prop)); } } class StatItem { constructor(name) { ___default.default.assign(this, {name, numRecomputes: 0, numUpdates: 0, computeTime: 0, updateTime: 0}); } add(item) { this.computeTime += item.computeTime; this.updateTime += item.updateTime; this.numUpdates += item.numUpdates; this.numRecomputes += item.numRecomputes; } get runtime() { return this.computeTime + this.updateTime; } get runtimePerRecompute() { return this.numRecomputes ? this.computeTime / this.numRecomputes : 0; } get runtimePerUpdate() { return this.numUpdates ? this.updateTime / this.numUpdates : 0; } toLogParts(totals) { return [ `${this.name}:`, ` ${(this.runtime / 1000).toFixed(2)}s`, `(${(this.runtime / totals.runtime * 100).toFixed(1)}%)`, ` ${this.numUpdates} upd /`, `${this.numRecomputes} runs`, `(${(this.numUpdates / this.numRecomputes * 100).toFixed(1)}%)`, ` ${this.runtimePerRecompute.toFixed(2)}ms / run`, ` ${this.runtimePerUpdate.toFixed(2)}ms / upd` ]; } } class Stats { constructor() { this._items = {}; } for(name) { if (!this._items[name]) this._items[name] = new StatItem(name); return this._items[name]; } get list() { return ___default.default(this._items).values().sortBy(item => -item.runtime).value(); } log(n = 10) { let stats = this.list; if (!stats.length) return; const totals = new StatItem('=== Total'); ___default.default.forEach(stats, stat => {totals.add(stat);}); stats = ___default.default.take(stats, n); const above = new StatItem('--- Above'); ___default.default.forEach(stats, stat => {above.add(stat);}); const lines = ___default.default.map(stats, item => item.toLogParts(totals)); lines.push(above.toLogParts(totals)); lines.push(totals.toLogParts(totals)); const widths = ___default.default.map(___default.default.range(lines[0].length), i => ___default.default(lines).map(line => line[i].length).max()); ___default.default.forEach(lines, line => { console.log(___default.default.map(line, (column, i) => ___default.default.padStart(column, widths[i])).join(' ')); }); } wrap(getter, className, propName) { const item = this.for(`${className}.${propName}`); return function() { /* eslint-disable no-invalid-this */ const startTime = performance.now(); const oldValue = this._computedWatchers && this._computedWatchers[propName].value; try { const newValue = getter.call(this); if (!isTrussEqual(oldValue, newValue)) item.numUpdates += 1; return newValue; } finally { item.computeTime += performance.now() - startTime; item.numRecomputes += 1; } }; } } var stats = new Stats(); class Connector { constructor(scope, connections, tree, method, refs) { Object.freeze(connections); this._scope = scope; this._connections = connections; this._tree = tree; this._method = method; this._subConnectors = {}; this._disconnects = {}; this._angularUnwatches = undefined; this._data = {}; this._vue = new Vue__default.default({data: { descriptors: {}, refs: refs || {}, values: ___default.default.mapValues(connections, ___default.default.constant(undefined)) }}); // allow instance-level overrides of destroy() method this.destroy = this.destroy; // eslint-disable-line no-self-assign Object.seal(this); this._linkScopeProperties(); ___default.default.forEach(connections, (descriptor, key) => { if (___default.default.isFunction(descriptor)) { this._bindComputedConnection(key, descriptor); } else { this._connect(key, descriptor); } }); if (angularProxy.active && scope && scope.$on && scope.$id) { scope.$on('$destroy', () => {this.destroy();}); } } get ready() { return ___default.default.every(this._connections, (ignored, key) => { const descriptor = this._vue.descriptors[key]; if (!descriptor) return false; if (descriptor instanceof Handle) return descriptor.ready; return this._subConnectors[key].ready; }); } get at() { return this._vue.refs; } get data() { return this._data; } destroy() { this._unlinkScopeProperties(); ___default.default.forEach(this._angularUnwatches, unwatch => {unwatch();}); ___default.default.forEach(this._connections, (descriptor, key) => {this._disconnect(key);}); this._vue.$destroy(); } _linkScopeProperties() { const dataProperties = ___default.default.mapValues(this._connections, (unused, key) => ({ configurable: true, enumerable: false, get: () => { const descriptor = this._vue.descriptors[key]; if (descriptor instanceof Reference) return descriptor.value; return this._vue.values[key]; } })); Object.defineProperties(this._data, dataProperties); if (this._scope) { for (const key in this._connections) { if (key in this._scope) { throw new Error(`Property already defined on connection target: ${key}`); } } Object.defineProperties(this._scope, dataProperties); if (this._scope.__ob__) this._scope.__ob__.dep.notify(); } } _unlinkScopeProperties() { if (!this._scope) return; ___default.default.forEach(this._connections, (descriptor, key) => { delete this._scope[key]; }); } _bindComputedConnection(key, fn) { const connectionStats = stats.for(`connection.at.${key}`); const getter = this._computeConnection.bind(this, fn, connectionStats); const update = this._updateComputedConnection.bind(this, key, fn, connectionStats); const angularWatch = angularProxy.active && !fn.angularWatchSuppressed; // Use this._vue.$watch instead of truss.observe here so that we can disable the immediate // callback if we'll get one from Angular anyway. this._vue.$watch(getter, update, {immediate: !angularWatch}); if (angularWatch) { if (!this._angularUnwatches) this._angularUnwatches = []; this._angularUnwatches.push(angularProxy.watch(getter, update, true)); } } _computeConnection(fn, connectionStats) { const startTime = performance.now(); try { return flattenRefs(fn.call(this._scope)); } finally { connectionStats.computeTime += performance.now() - startTime; connectionStats.numRecomputes += 1; } } _updateComputedConnection(key, value, connectionStats) { const newDescriptor = ___default.default.isFunction(value) ? value(this._scope) : value; const oldDescriptor = this._vue.descriptors[key]; const descriptorChanged = !isTrussEqual(oldDescriptor, newDescriptor); if (!descriptorChanged) return; if (connectionStats && descriptorChanged) connectionStats.numUpdates += 1; if (!newDescriptor) { this._disconnect(key); return; } if (newDescriptor instanceof Handle || !___default.default.has(this._subConnectors, key)) { this._disconnect(key); this._connect(key, newDescriptor); } else { this._subConnectors[key]._updateConnections(newDescriptor); } Vue__default.default.set(this._vue.descriptors, key, newDescriptor); angularProxy.digest(); } _updateConnections(connections) { ___default.default.forEach(connections, (descriptor, key) => { this._updateComputedConnection(key, descriptor); }); ___default.default.forEach(this._connections, (descriptor, key) => { if (!___default.default.has(connections, key)) this._updateComputedConnection(key); }); this._connections = connections; } _connect(key, descriptor) { Vue__default.default.set(this._vue.descriptors, key, descriptor); angularProxy.digest(); if (!descriptor) return; Vue__default.default.set(this._vue.values, key, undefined); if (descriptor instanceof Reference) { Vue__default.default.set(this._vue.refs, key, descriptor); this._disconnects[key] = this._tree.connectReference(descriptor, this._method); } else if (descriptor instanceof Query) { Vue__default.default.set(this._vue.refs, key, descriptor); const updateFn = this._updateQueryValue.bind(this, key); this._disconnects[key] = this._tree.connectQuery(descriptor, updateFn, this._method); } else { const subScope = {}, subRefs = {}; Vue__default.default.set(this._vue.refs, key, subRefs); const subConnector = this._subConnectors[key] = new Connector(subScope, descriptor, this._tree, this._method, subRefs); // Use a truss.observe here instead of this._vue.$watch so that the "immediate" execution // actually takes place after we've captured the unwatch function, in case the subConnector // is ready immediately. const unobserve = this._disconnects[key] = this._tree.truss.observe( () => subConnector.ready, subReady => { if (!subReady) return; unobserve(); delete this._disconnects[key]; Vue__default.default.set(this._vue.values, key, subScope); angularProxy.digest(); } ); } } _disconnect(key) { Vue__default.default.delete(this._vue.refs, key); this._updateRefValue(key, undefined); if (___default.default.has(this._subConnectors, key)) { this._subConnectors[key].destroy(); delete this._subConnectors[key]; } if (this._disconnects[key]) this._disconnects[key](); delete this._disconnects[key]; Vue__default.default.delete(this._vue.descriptors, key); angularProxy.digest(); } _updateRefValue(key, value) { if (this._vue.values[key] !== value) { Vue__default.default.set(this._vue.values, key, value); angularProxy.digest(); } } _updateQueryValue(key, childKeys) { if (!this._vue.values[key]) { Vue__default.default.set(this._vue.values, key, {}); angularProxy.digest(); } const subScope = this._vue.values[key]; for (const childKey in subScope) { if (!Object.hasOwn(subScope, childKey)) continue; if (!___default.default.includes(childKeys, childKey)) { Vue__default.default.delete(subScope, childKey); angularProxy.digest(); } } const object = this._tree.getObject(this._vue.descriptors[key].path); for (const childKey of childKeys) { if (Object.hasOwn(subScope, childKey)) continue; Vue__default.default.set(subScope, childKey, object[childKey]); angularProxy.digest(); } } } function flattenRefs(refs) { if (!refs) return; if (refs instanceof Handle) return refs.toString(); return ___default.default.mapValues(refs, flattenRefs); } function wrapPromiseCallback(callback) { return function() { try { // eslint-disable-next-line no-invalid-this return Promise.resolve(callback.apply(this, arguments)); } catch (e) { return Promise.reject(e); } }; } function promiseCancel(promise, cancel) { promise = promiseFinally(promise, () => {cancel = null;}); promise.cancel = () => { if (!cancel) return; cancel(); cancel = null; }; propagatePromiseProperty(promise, 'cancel'); return promise; } function propagatePromiseProperty(promise, propertyName) { const originalThen = promise.then, originalCatch = promise.catch; promise.then = (onResolved, onRejected) => { const derivedPromise = originalThen.call(promise, onResolved, onRejected); derivedPromise[propertyName] = promise[propertyName]; propagatePromiseProperty(derivedPromise, propertyName); return derivedPromise; }; promise.catch = onRejected => { const derivedPromise = originalCatch.call(promise, onRejected); derivedPromise[propertyName] = promise[propertyName]; propagatePromiseProperty(derivedPromise, propertyName); return derivedPromise; }; return promise; } function promiseFinally(promise, onFinally) { if (!onFinally) return promise; onFinally = wrapPromiseCallback(onFinally); return promise.then(result => { return onFinally().then(() => result); }, error => { return onFinally().then(() => Promise.reject(error)); }); } const INTERCEPT_KEYS = [ 'read', 'write', 'auth', 'set', 'update', 'commit', 'connect', 'peek', 'authenticate', 'unathenticate', 'certify', 'all' ]; const EMPTY_ARRAY = []; class SlowHandle { constructor(operation, delay, callback) { this._operation = operation; this._delay = delay; this._callback = callback; this._fired = false; } initiate() { this.cancel(); this._fired = false; const elapsed = Date.now() - this._operation._startTimestamp; this._timeoutId = setTimeout(() => { this._fired = true; this._callback(this._operation); }, this._delay - elapsed); } cancel() { if (this._fired) this._callback(this._operation); if (this._timeoutId) clearTimeout(this._timeoutId); } } class Operation { constructor(type, method, target, operand) { this._type = type; this._method = method; this._target = target; this._operand = operand; this._ready = false; this._running = false; this._ended = false; this._tries = 0; this._startTimestamp = Date.now(); this._slowHandles = []; } get type() {return this._type;} get method() {return this._method;} get target() {return this._target;} get targets() { if (this._method !== 'update') return [this._target]; return ___default.default.map(this._operand, (value, escapedPathFragment) => { return new Reference( this._target._tree, joinPath(this._target.path, escapedPathFragment), this._target._annotations); }); } get operand() {return this._operand;} get ready() {return this._ready;} get running() {return this._running;} get ended() {return this._ended;} get tries() {return this._tries;} get error() {return this._error;} onSlow(delay, callback) { const handle = new SlowHandle(this, delay, callback); this._slowHandles.push(handle); handle.initiate(); } _setRunning(value) { this._running = value; } _setEnded(value) { this._ended = value; } _markReady(ending) { this._ready = true; if (!ending) this._tries = 0; ___default.default.forEach(this._slowHandles, handle => handle.cancel()); } _clearReady() { // Temporarily set ready to correctly reset previously triggered slow handles. this._ready = true; this._startTimestamp = Date.now(); ___default.default.forEach(this._slowHandles, handle => handle.initiate()); this._ready = false; } _incrementTries() { this._tries++; } } class Dispatcher { constructor(bridge) { this._bridge = bridge; this._callbacks = {}; Object.freeze(this); } intercept(interceptKey, callbacks) { if (!___default.default.includes(INTERCEPT_KEYS, interceptKey)) { throw new Error('Unknown intercept operation type: ' + interceptKey); } const badCallbackKeys = ___default.default.difference(___default.default.keys(callbacks), ['onBefore', 'onAfter', 'onError', 'onFailure']); if (badCallbackKeys.length) { throw new Error('Unknown intercept callback types: ' + badCallbackKeys.join(', ')); } const wrappedCallbacks = { onBefore: this._addCallback('onBefore', interceptKey, callbacks.onBefore), onAfter: this._addCallback('onAfter', interceptKey, callbacks.onAfter), onError: this._addCallback('onError', interceptKey, callbacks.onError), onFailure: this._addCallback('onFailure', interceptKey, callbacks.onFailure) }; return this._removeCallbacks.bind(this, interceptKey, wrappedCallbacks); } _addCallback(stage, interceptKey, callback) { if (!callback) return; const key = this._getCallbacksKey(stage, interceptKey); const wrappedCallback = wrapPromiseCallback(callback); (this._callbacks[key] || (this._callbacks[key] = [])).push(wrappedCallback); return wrappedCallback; } _removeCallback(stage, interceptKey, wrappedCallback) { if (!wrappedCallback) return; const key = this._getCallbacksKey(stage, interceptKey); if (this._callbacks[key]) ___default.default.pull(this._callbacks[key], wrappedCallback); } _removeCallbacks(interceptKey, wrappedCallbacks) { ___default.default.forEach(wrappedCallbacks, (wrappedCallback, stage) => { this._removeCallback(stage, interceptKey, wrappedCallback); }); } _getCallbacks(stage, operationType, method) { return [].concat( this._callbacks[this._getCallbacksKey(stage, method)] || EMPTY_ARRAY, this._callbacks[this._getCallbacksKey(stage, operationType)] || EMPTY_ARRAY, this._callbacks[this._getCallbacksKey(stage, 'all')] || EMPTY_ARRAY ); } _getCallbacksKey(stage, interceptKey) { return `${stage}_${interceptKey}`; } execute(operationType, method, target, operand, executor) { executor = wrapPromiseCallback(executor); const operation = this.createOperation(operationType, method, target, operand); return this.begin(operation).then(() => { const executeWithRetries = () => { return executor().catch(e => this._retryOrEnd(operation, e).then(executeWithRetries)); }; return executeWithRetries(); }).then(result => this.end(operation).then(() => result)); } createOperation(operationType, method, target, operand) { return new Operation(operationType, method, target, operand); } begin(operation) { return Promise.all(___default.default.map( this._getCallbacks('onBefore', operation.type, operation.method), onBefore => onBefore(operation) )).then(() => { if (!operation.ended) operation._setRunning(true); }, e => this.end(operation, e)); } markReady(operation) { operation._markReady(); } clearReady(operation) { operation._clearReady(); } retry(operation, error) { operation._incrementTries(); operation._error = error; return Promise.all(___default.default.map( this._getCallbacks('onError', operation.type, operation.method), onError => onError(operation, error) )).then(results => { // If the operation ended in the meantime, bail. This will cause the caller to attempt to // fail the operation, but since it's already ended the call to end() with an error will be a // no-op. if (operation.ended) return; const retrying = ___default.default.some(results); if (retrying) delete operation._error; return retrying; }); } _retryOrEnd(operation, error) { return this.retry(operation, error).then(result => { if (!result) return this.end(operation, error); }, e => this.end(operation, e)); } end(operation, error) { if (operation.ended) return Promise.resolve(); operation._setRunning(false); operation._setEnded(true); if (error) {